Back to Blog

The Inventory Management System: Choosing Bagisto and Wiring It Into the Clinic's EHR

· 13 min read
Share:
The Inventory Management System: Choosing Bagisto and Wiring It Into the Clinic's EHR

Part of the Technical Breakdown series on my 4-year engagement with Prestige Men's Health.

By mid-2025, the clinic's needs had grown past "schedule patients, text them, sync the EHR." Dr. Schmidt came to me needing real inventory management and a point-of-sale interface — the clinic sells physical product (supplements, injectable therapies) in-office, and that side of the business had outgrown spreadsheets. This post covers that build: why I picked Bagisto, how it got wired into the existing EHR and SMS platform, and the real production issues that came with running a second, product-based system alongside a services-based one.

Why Bagisto

Three reasons, in order of how much they mattered:

  1. It's Laravel. The EHR (agentirx), the SMS platform, the Tebra sync — everything was already Laravel. Picking an e-commerce platform in a different stack would have meant a second set of conventions, a second deployment pipeline, and no code or team knowledge reuse. Bagisto being Laravel-native meant the same engineering patterns — service providers, Eloquent, queues, Filament-adjacent admin conventions — carried straight over.

  2. It's modular. Bagisto ships as a set of independent Webkul packages (packages/Webkul/Pos, packages/Webkul/Inventory, packages/Webkul/Payment, packages/Webkul/Shipping, and so on) rather than one monolithic app. That matters enormously for a clinic-specific build: you extend or replace a package without touching the others.

  3. It has a real plugin ecosystem. Bagisto has both free and paid marketplace extensions for things like POS hardware integration and shipping carriers. Building a POS from zero — barcode scanning, cash drawer handling, split-tender payments — is months of work; starting from a maintained package and customizing it is weeks. That ecosystem is what made the timeline realistic.

Setting it up: June 2025

The store (prestige-store) started from a clean Bagisto install on June 19, 2025. Within four days, the real customization work started — and the very first thing that had to change was payment processing.

Problem: a payment gateway customization that vendor updates would silently erase

The clinic's existing merchant processor was Authorize.Net — the same one already wired into the EHR side for service payments. Bagisto ships its own stock Authorize.Net package, but customizing it directly (editing files inside packages/Webkul/AuthorizeNet) is exactly the kind of decision that looks fine until the next composer update quietly reverts your changes along with the vendor's.

// WRONG APPROACH — editing packages/Webkul/AuthorizeNet directly
// Works today. Gets silently overwritten the next time Bagisto core
// or the AuthorizeNet package itself gets updated.
// RIGHT APPROACH — a separate first-class package
// packages/Webkul/CustomAuthorizeNet/src/CustomAuthorizeNetServiceProvider.php (conceptual)
class CustomAuthorizeNetServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
        $this->mergeConfigFrom(__DIR__.'/../config/custom_authorizenet.php', 'custom_authorizenet');

        // Registers as its own payment method in Bagisto's payment
        // registry — doesn't touch, extend, or depend on internals
        // of the stock AuthorizeNet package at all.
    }
}

A real commit captures the exact moment this decision got made: "custom auth net module so does not get overwritten," followed shortly after by "remove orig authnet modules since we now have custom one." That's the architectural lesson of this whole project in one line — when a vendor package almost does what you need, extract your own package rather than patching theirs. It's more work up front and it's the only version of this that survives an upgrade.

Problem: saved cards, split tender, and not double-charging anyone

Once the custom Authorize.Net module existed, the POS needed real-world checkout flows a basic integration doesn't give you for free:

  • Saved card selection at the register, so a returning patient doesn't re-enter a card every visit.

  • Split tender — part cash, part card on the same order ("split cash + card which is now authorize.net").

  • A cash drawer rule: exactly one open cash drawer per cashier per day, enforced at the database level, because two concurrent drawer sessions for the same user is a reconciliation nightmare at closing time.

// AFTER — app/Services/Pos/CashDrawerService.php (conceptual)
public function openDrawer(User $cashier): CashDrawer
{
    $alreadyOpenToday = CashDrawer::query()
        ->where('user_id', $cashier->id)
        ->whereDate('opened_at', today())
        ->whereNull('closed_at')
        ->exists();

    throw_if($alreadyOpenToday, DrawerAlreadyOpenException::class);

    return CashDrawer::create(['user_id' => $cashier->id, 'opened_at' => now()]);
}

The trickier bug was on the saved-card side: after adding or deleting a stored card, the POS frontend kept showing stale card data because of an intermediate GraphQL response cache — fixed by explicitly excluding customer-payment-profile queries from the cache layer (ExcludeGraphQLCacheProfile.php), the same general lesson as the SMS platform's conversation-list caching: anything that reflects a just-changed state needs to be excluded from, not just invalidated in, a response cache. On the refund side, a transaction-ID-linked event listener (RefundListener) ties a POS refund back to the original Authorize.Net transaction automatically rather than requiring a manual lookup — note from a real commit along the way: "wtf refund bs!" — refund flows across a payment gateway are reliably harder than the happy-path charge, on every platform I've worked with.

Worth being explicit about what's not stored anywhere in this flow: raw card numbers never touch the application database. What's stored is Authorize.Net's own tokenized customer-profile reference — the processor holds the card, the application holds a pointer to it. That's standard, correct practice for any PCI-scoped integration, not a clinic-specific detail.

Authorize.Net wasn't the only gateway in the end, either — a second processor connection, NMI, got wired into the same checkout flow (auto import by phone on checkout signup + nmi and saved cards), giving the clinic a fallback path if one processor had an outage, rather than a single point of failure on payments.

Problem: tracking inventory by SKU, barcode, and incoming batch

A men's health clinic selling injectable therapies has an inventory problem a typical retail POS doesn't: the physical items are small vials, not shelf products with room for a standard barcode label, and a single product can arrive in multiple incoming batches that staff need to tell apart. This became a real custom build on top of Bagisto's own Pos package barcode system (PosProductBarcode, the barcode-scanner frontend composable, the admin barcode print views) rather than a bolt-on:

  • Multi-SKU-per-product barcoding — a single catalog product can have more than one SKU/barcode combination, so variants and repackaged units aren't forced into one generic code.

  • Small-bottle label sizing — a dedicated small-bottle barcode service and database fields, because standard label dimensions don't fit on a vial.

  • Batch assignment on receiving — when a new shipment comes in, staff assign the incoming batch to a SKU and print barcodes for that specific batch, so a product restock isn't indistinguishable from the batch that arrived three months earlier. That's the difference between "we have 40 units of X in stock" and "we have 40 units of X, 12 from batch A and 28 from batch B" — the second one is what you actually need if a batch-specific issue ever comes up.

  • Handheld scanning at the register — a barcode-scanner integration on the POS frontend itself, so checkout and stock lookups happen by scan, not manual SKU entry.

Problem: the USPS shipping integration that never quite worked

This one's a good war story because the resolution was "stop fighting it, switch providers." The store needed to generate real shipping labels for mail-order product fulfillment, and the first approach was integrating directly against USPS's own API:

  • update USPS use customer key and secret new API

  • usps labels and API update

  • test orig shippy package connection

  • revert back to orig shippy code USPS

  • new usps shippy test get code

  • USPS legacy API test (nothing works!)

After roughly two months of intermittent attempts against USPS's legacy and newer APIs — each one with its own auth model, neither fully reliable — the fix was abandoning the direct integration entirely in favor of EasyPost, a shipping-API aggregator that normalizes USPS (and other carriers) behind one consistent interface. Label generation went from "nothing works" to solved within days of switching providers. The lesson generalizes: when you've spent more time fighting a vendor's API than using it, the fastest fix is sometimes a different vendor, not more persistence with the same one. A custom shipping module sits on top of that EasyPost connection specifically to translate a Bagisto order into a label request and write the resulting tracking number straight back onto the order — not something the stock shipping package does out of the box.

Architecture: two systems, one server, two ways of talking to each other

The store and the EHR run on the same server, which opened up an option a fully separate-infrastructure setup wouldn't have: the EHR's Laravel app holds its own normal connector layer (the BagistoOrderService and repair commands below) for anything that writes to the store, plus a second, direct database connection configured straight into Bagisto's own database for read-heavy views where going through an API layer would just be slower for no safety benefit — a Filament "Inventory Control" section inside the EHR shows live stock levels, low-stock alerts, and recent store orders by querying Bagisto's tables directly, without round-tripping through HTTP.

The split matters: reads go direct, because a dashboard widget querying stock levels doesn't need to go through a service layer to be correct. Writes — creating an order, linking a customer, applying a payment — stay routed through the dedicated service classes, because that's where the matching, dedup, and reconciliation logic actually lives. Letting two separate Laravel apps both write directly to the same tables without a shared service layer is how you get the exact mislinked/orphaned-order problems the repair commands below exist to clean up — so the rule became: direct connection for reading, service layer for writing.

Connecting the store back to the EHR

A standalone store is only useful if it knows who your patients are. The connector layer lives on the EHR side (app/Services/BagistoOrderService.php), and a set of dedicated repair commands exist specifically because keeping two separate systems' customer records in sync is never perfectly clean in practice:

  • FixMislinkedBagistoOrders — reconciles orders that got attached to the wrong store customer record

  • FixOrphanedBagistoOrders — catches orders with no matching EHR client at all

  • FixClientBagistoCustomer — repairs the client-to-store-customer link directly

  • BagistoRepairAll — runs the full repair suite in one pass

// app/Console/Commands/BagistoRepairAll.php (conceptual)
public function handle(): int
{
    $this->call(FixOrphanedBagistoOrders::class);
    $this->call(FixMislinkedBagistoOrders::class);
    $this->call(FixClientBagistoCustomer::class);

    return self::SUCCESS;
}

All synced store transactions also surface directly inside the main EHR as a Filament resource, so staff never have to log into a second admin panel just to check whether a patient's order went through. The same Filament layer handles custom order views, low-stock alerts, and inventory control screens — the operational front end staff actually use day to day, with Bagisto's own admin reserved for store/catalog configuration rather than daily inventory work.

Linking inventory to prescriptions and patients

The real point of all the connector and reconciliation work above is this: an inventory item isn't just a SKU in a catalog, it's tied to a specific patient and, where relevant, a specific prescription order. That link is what makes it possible to answer "which patient is this batch going to" or "do we have enough of this product in stock to fill the Rx orders on the schedule this week" without manually cross-referencing two systems by hand. It's also what the Rx pipeline (covered in its own deep-dive) relies on when a prescription order needs to pull from physical stock rather than just generating a fax.

The SMS connection: ordering product through a text message

This is where the series comes full circle. One real commit says it plainly: "sms order products with USPS API and bagisto order creation." The AI tool layer built on top of the SMS platform (covered in the SMS deep-dive) can take a patient's texted request for a refill or product reorder and create an actual Bagisto order and shipping label — no staff member manually re-keying anything into the store. A patient texting "can I get another month of X" can, through the agent tool chain, end up with a real order in the inventory system and a shipping label generated, entirely through the channel they already use to talk to the clinic.

The inventory side got real, verifiable AI involvement too — EMA, the clinic's AI system referenced throughout this series, lives entirely on the EHR side (it's never part of the store codebase itself), and reaches into Bagisto's data through that same cross-database connection described above rather than through the store's own code. An inventory reconciliation page was built in November 2025 to create IMS orders directly from mismatches between expected and physical stock; by April 2026 that had grown into a full reconciliation wizard with its own predictions ("ims wizard all meds sent to ai," "advanced ims recon wizard + rebuild," "ai IMS auto orders"), and by July 2026 EMA had dedicated, tested tooling for it — a product-line resolver (EmaProductLineResolver) and a store-inventory-adjustment service (StoreInventoryAdjustmentService), both covered by their own test suites (EmaStoreToolsTest, EmaProductLineResolverTest). That's the difference between "an AI feature" and a verified tool layer: EMA doesn't just read inventory data, it has a scoped, tested action (StoreInventoryAdjustmentService) it can call to adjust stock, in the same narrow-tool pattern used for the SMS agent tools in the SMS deep-dive.

The SMS-to-IMS order flow benefited from the same build-out: orders created from a text now carry real shipping weight (in ounces) for accurate label costs, a tracking code and URL sent straight back to the patient over SMS, and color-coded stock-level info in the SMS order view so staff can see at a glance whether what was just ordered is actually on the shelf.

The storefront theme, and the AI agents again

Starting in early 2026, the storefront's visual layer got a full rebuild around a purchased commercial admin theme (Metronic), brought in as a regular part of the codebase rather than a loosely-tracked submodule, then integrated into Bagisto's own visual theme editor and moved to Tailwind. Like the other subsystems in this series, a meaningful chunk of this was autonomous Copilot coding-agent work: copilot/integrate-metronic-theme-assets, copilot/update-custom-theme-with-tailwind, copilot/add-sections-to-visual-theme-editor, copilot/fix-theme-css-overwrite — each a real, independently reviewed PR. The honest commit history includes at least one moment of "will this theme ever work!!!??" before it did — frontend theme integration work has a way of looking simple from the outside and not being simple at all.


This is part of a five-post series on the Prestige Men's Health platform — see the main technical breakdown, or the deep-dives on the SMS communication platform, the Kareo/Tebra EHR sync, the Rx order pipeline, and the patient portal.

Share:

Comments

No comments yet — be the first to share your thoughts.

Leave a comment

Your comment will be reviewed before it appears publicly.

Never published — only used if we need to reach you.

More from the blog