The Technical Breakdown: From a Hand-Audited Payment Mess to an Autonomous, AI-Operated Commerce Platform
A follow-up to How I Started at Prestige Men's Health.
The first post covered how I got pulled into this engagement with Dr. Joshua Schmidt and Prestige Men's Health. This one covers the roughly four years that followed, starting in early 2023: the manual forensic audit and rebuild of the payment/affiliate core, the shift from hand-written code to Copilot-assisted shipping, and the final phase — aggressively adopting agentic engineering (Cursor, Claude Code, and the Anthropic model stack) to the point where Dr. Schmidt could run day-to-day product evolution himself. I'm still keeping the original contracting agency unnamed, same as last time — this is a breakdown of engineering decisions, not a callout of the people who wrote the first version.
Stack: Laravel 12 on a customized Bagisto storefront, dual payment gateways (Authorize.Net for acquisition, NMI for recurring vault billing), a fully custom affiliate/referral commission engine, and a location-aware inventory tracker synced to subscription cadence.
Before the Audit: The Systems I Built From Scratch
The audit and refactor work in Part 1 is what I inherited and fixed. Before and alongside it, a meaningful share of this engagement was original systems I built from zero — not legacy code I cleaned up, but core patient-facing infrastructure that didn't exist yet.
The SMS communication platform. Starting in October 2023, I built the clinic's two-way patient texting platform from scratch — a Twilio-backed messaging system with real-time delivery over Laravel Echo/WebSockets, so a message sent to a patient shows up instantly in the front desk's conversation view without a page refresh. Part of standing it up was migrating roughly three years of the clinic's prior message history from their old messaging tool into the new system, attachments included, so staff didn't lose a single existing patient thread on cutover. That platform is still the clinic's primary patient communication channel, and years later it became the execution surface for a set of AI agent tools — scheduled sends, bulk group messaging, booking-link delivery, payment-link delivery — that the autonomous AI layer described in Part 3 operates through directly. It wasn't just a messaging feature; it ended up being the thing the AI agents actually act through.
The Rx order pipeline. Starting mid-2023, I built the prescription order workflow end to end — order creation, PDF/fax generation routed to the correct pharmacy — and later layered on a pharmacy-manager admin view and ePrescription actions.
The Kareo/Tebra EHR sync. Starting in 2024, I built the data-sync layer against Kareo, the clinic's EHR system at the time — pulling and reconciling patient records by API on a schedule. When Kareo rebranded to Tebra and moved to a FHIR-based API, I rebuilt that integration around FHIR bulk import/export rather than letting it rot on a deprecated API surface.
The patient portal. The original agency had shipped a first-pass version before I joined. Starting in 2024 I rebuilt it on Filament and kept extending it from there — email verification, live chat, self-service Rx/lab-result visibility, and the account-management flows patients actually used day to day.
Part 1 — The Forensic Audit (Hand-Built Era)
Everything in this section was found and fixed by hand, over roughly the first year and a half, with zero AI tooling involved.
1.1 Gateway logic duplicated at the controller level
Two gateways, two controllers, zero shared abstraction. Authorize.Net handled new-customer acquisition charges; NMI handled recurring vault billing against stored payment methods. Both had independently reimplemented "validate the request, call the vendor SDK, persist a transaction row, notify" — with incompatible status vocabularies and credentials pulled via raw env() calls from inside business logic.
// BEFORE — app/Http/Controllers/AuthorizeNetController.php
class AuthorizeNetController extends Controller
{
public function charge(Request $request)
{
$merchantAuth = new AnetAPI\MerchantAuthenticationType();
$merchantAuth->setName(env('AUTHNET_LOGIN_ID'));
$merchantAuth->setTransactionKey(env('AUTHNET_TRANSACTION_KEY'));
$creditCard = new AnetAPI\CreditCardType();
$creditCard->setCardNumber($request->input('card_number'));
$creditCard->setExpirationDate($request->input('expiry'));
DB::table('transactions')->insert([
'customer_id' => $request->input('customer_id'),
'amount' => $request->input('amount'),
'gateway' => 'authnet',
'status' => $response->getMessages()->getResultCode(), // e.g. "Ok"
'created_at' => now(),
]);
return response()->json(['ok' => true]);
}
}
// BEFORE — app/Http/Controllers/NmiController.php
class NmiController extends Controller
{
public function charge(Request $request)
{
$response = Http::asForm()->post('https://secure.networkmerchants.com/api/transact.php', [
'username' => env('NMI_USERNAME'),
'password' => env('NMI_PASSWORD'),
'customer_vault_id' => $request->input('vault_id'),
'amount' => $request->input('amount'),
'type' => 'sale',
]);
DB::table('transactions')->insert([
'customer_id' => $request->input('customer_id'),
'amount' => $request->input('amount'),
'gateway' => 'nmi',
'status' => str_contains($response->body(), 'response=1') ? 'approved' : 'declined',
'created_at' => now(),
]);
return response()->json(['ok' => true]);
}
}
Fixed with a single contract both gateways implement, a factory to resolve the right one, and credentials pulled from a dedicated store instead of scattered env() calls:
// AFTER — app/Payments/Contracts/PaymentGatewayInterface.php
interface PaymentGatewayInterface
{
public function charge(ChargeRequest $request): GatewayChargeResult;
public function refund(string $externalTransactionId, int $amountCents): GatewayChargeResult;
public function verifyWebhookSignature(Request $request): bool;
public function normalizeWebhookPayload(Request $request): GatewayWebhookEvent;
}
// AFTER — app/Payments/Gateways/AuthorizeNetGateway.php
final class AuthorizeNetGateway implements PaymentGatewayInterface
{
public function __construct(private readonly AuthorizeNetCredentials $credentials)
{
}
public function charge(ChargeRequest $request): GatewayChargeResult
{
$merchantAuth = new AnetAPI\MerchantAuthenticationType();
$merchantAuth->setName($this->credentials->loginId());
$merchantAuth->setTransactionKey($this->credentials->transactionKey());
$response = $this->controller($request, $merchantAuth)
->executeWithApiResponse($this->credentials->environment());
return GatewayChargeResult::fromAuthorizeNetResponse($response);
}
}
// AFTER — app/Payments/PaymentGatewayFactory.php
final class PaymentGatewayFactory
{
public function __construct(private readonly SystemCredentialStore $credentials)
{
}
public function make(string $gateway): PaymentGatewayInterface
{
return match ($gateway) {
'authnet' => new AuthorizeNetGateway($this->credentials->authorizeNet()),
'nmi' => new NmiGateway($this->credentials->nmi()),
default => throw new UnsupportedGatewayException($gateway),
};
}
}
GatewayChargeResult normalizes both vendors' wildly different response shapes into one status: Status enum (Approved, Declined, Error) before it ever reaches a controller. Nothing downstream needs to know which vendor processed the charge.
1.2 Webhooks with zero idempotency, causing silent double-billing
Neither gateway's webhook handler checked whether it had already processed a given external transaction ID before writing a new row. Both vendors retry delivery on anything other than a clean 200 within their timeout window — and under a retry storm, that silently double-booked charges.
The fix is two layers: a Redis-backed distributed lock as the fast-path guard against concurrent delivery, and a database unique constraint as the structural backstop that holds even if the lock layer expires mid-request.
// AFTER — database/migrations/2025_xx_xx_add_transaction_idempotency.php
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->unique(['gateway', 'external_transaction_id']);
});
}
// AFTER — app/Http/Controllers/PaymentWebhookController.php
final class PaymentWebhookController extends Controller
{
public function __construct(
private readonly PaymentGatewayFactory $gateways,
private readonly RecordGatewayTransaction $recordTransaction,
) {
}
public function handle(Request $request, string $gateway): Response
{
$gatewayClient = $this->gateways->make($gateway);
if (! $gatewayClient->verifyWebhookSignature($request)) {
Log::warning('payments.webhook.signature_mismatch', ['gateway' => $gateway]);
return response()->noContent(401);
}
$event = $gatewayClient->normalizeWebhookPayload($request);
$lockKey = "webhook-lock:{$gateway}:{$event->externalTransactionId}";
$processed = Cache::lock($lockKey, seconds: 10)->block(5, function () use ($event) {
return $this->recordTransaction->handle($event);
});
if ($processed === false) {
Log::info('payments.webhook.concurrent_duplicate', [
'gateway' => $gateway,
'external_transaction_id' => $event->externalTransactionId,
]);
}
return response()->noContent(200);
}
}
// AFTER — app/Payments/Actions/RecordGatewayTransaction.php
final class RecordGatewayTransaction
{
public function handle(GatewayWebhookEvent $event): Transaction
{
return Transaction::query()->firstOrCreate(
[
'gateway' => $event->gateway,
'external_transaction_id' => $event->externalTransactionId,
],
[
'customer_id' => $event->customerId,
'amount_cents' => $event->amountCents,
'status' => $event->status,
'raw_payload' => $event->rawPayload,
],
);
}
}
The lock makes the common case fast and quiet. The constraint makes the guarantee absolute.
1.3 Raw SQL string concatenation in the affiliate reconciliation job
The monthly affiliate payout calculation (CalculateAffiliatePayouts) was the single scariest file in the codebase — a ~400-line Artisan command building its own SQL with the reporting period interpolated directly into the string:
// BEFORE — app/Console/Commands/CalculateAffiliatePayouts.php
$startDate = $this->argument('start_date');
$endDate = $this->argument('end_date');
$rows = DB::select("
SELECT affiliate_id, SUM(commission_amount) as total
FROM affiliate_ledger_entries
WHERE created_at BETWEEN '{$startDate}' AND '{$endDate}'
GROUP BY affiliate_id
");
// AFTER — app/Affiliates/Actions/AggregateLedgerEntriesForPeriod.php
final class AggregateLedgerEntriesForPeriod
{
/**
* @return Collection<int, object{affiliate_id: int, total_cents: int}>
*/
public function handle(CarbonImmutable $periodStart, CarbonImmutable $periodEnd): Collection
{
return AffiliateLedgerEntry::query()
->whereBetween('created_at', [$periodStart, $periodEnd])
->whereNull('payout_batch_id')
->selectRaw('affiliate_id, SUM(commission_amount_cents) as total_cents')
->groupBy('affiliate_id')
->get();
}
}
Every bound value now goes through Query Builder's parameter binding, and the aggregation moved out of a 400-line command into a single-purpose, independently testable action class.
1.4 N+1 query storms in the affiliate "my earnings" dashboard
The dashboard loaded an affiliate's entire ledger history, then looped in PHP to total it per payout batch and per referral tier — a fresh query every iteration. On an affiliate with real history, one page load issued 800+ queries.
// BEFORE — app/Http/Controllers/AffiliateDashboardController.php
public function earnings(Affiliate $affiliate)
{
$entries = AffiliateLedgerEntry::where('affiliate_id', $affiliate->id)->get();
$byBatch = [];
foreach ($entries as $entry) {
$batch = PayoutBatch::find($entry->payout_batch_id); // N queries
$byBatch[$batch->id]['total'] = ($byBatch[$batch->id]['total'] ?? 0) + $entry->commission_amount;
$tier = ReferralTier::find($entry->referral_tier_id); // N more queries
$byTier[$tier->name] = ($byTier[$tier->name] ?? 0) + $entry->commission_amount;
}
return view('affiliate.earnings', compact('byBatch', 'byTier'));
}
// AFTER — app/Affiliates/Actions/SummarizeAffiliateEarnings.php
final class SummarizeAffiliateEarnings
{
public function byPayoutBatch(Affiliate $affiliate): Collection
{
return $affiliate->ledgerEntries()
->with('payoutBatch:id,period_start,period_end,status')
->selectRaw('payout_batch_id, SUM(commission_amount_cents) as total_cents')
->groupBy('payout_batch_id')
->get();
}
public function byReferralTier(Affiliate $affiliate): Collection
{
return $affiliate->ledgerEntries()
->join('referral_tiers', 'referral_tiers.id', '=', 'affiliate_ledger_entries.referral_tier_id')
->selectRaw('referral_tiers.name, SUM(commission_amount_cents) as total_cents')
->groupBy('referral_tiers.name')
->get();
}
}
// AFTER — app/Http/Controllers/AffiliateDashboardController.php
public function earnings(Affiliate $affiliate, SummarizeAffiliateEarnings $summarize)
{
return view('affiliate.earnings', [
'byBatch' => $summarize->byPayoutBatch($affiliate),
'byTier' => $summarize->byReferralTier($affiliate),
]);
}
Eight hundred-plus queries collapsed into two — one aggregated query per breakdown, with eager loading covering the single relationship the view actually needed.
1.5 Mass Assignment
The original ledger and payout models were declared protected $guarded = []; — which meant any controller accepting a loosely-validated request array could write to columns like status, payout_batch_id, or compliance_hold_reason that should only ever be set by internal domain logic.
// BEFORE — packages/Webkul/AffiliateSystem/src/Models/AffiliateLedgerEntry.php
class AffiliateLedgerEntry extends Model
{
protected $guarded = []; // everything is fillable — including status, payout_batch_id...
}
// BEFORE — app/Http/Controllers/Admin/LedgerEntryController.php
public function update(Request $request, AffiliateLedgerEntry $entry)
{
$entry->update($request->all()); // whatever the client sends, writes straight through
return redirect()->back();
}
// AFTER — packages/Webkul/AffiliateSystem/src/Models/AffiliateLedgerEntry.php
class AffiliateLedgerEntry extends Model
{
protected $fillable = [
'affiliate_id',
'order_id',
'commission_amount_cents',
'referral_tier_id',
'notes',
];
}
// AFTER — app/Http/Requests/Admin/UpdateLedgerEntryRequest.php
final class UpdateLedgerEntryRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('update', $this->route('entry'));
}
public function rules(): array
{
return [
'commission_amount_cents' => ['required', 'integer', 'min:0'],
'referral_tier_id' => ['required', 'exists:referral_tiers,id'],
'notes' => ['nullable', 'string', 'max:500'],
];
}
}
// AFTER — app/Http/Controllers/Admin/LedgerEntryController.php
public function update(UpdateLedgerEntryRequest $request, AffiliateLedgerEntry $entry)
{
$entry->update($request->validated());
return redirect()->back();
}
$guarded = [] and $request->all() are each individually defensible in isolation — paired together they mean the model's allow-list for "safe to mass-assign" is defined nowhere at all.
Part 2 — Database & Schema Redesign
Bagisto resolves every model behind a Proxy class (AffiliateLedgerEntryProxy, AffiliatePayoutBatchProxy) so downstream apps can swap the concrete implementation via the container without touching vendor code. That indirection is invisible to the query planner — it still compiles to the same underlying table, so every index decision below applies to the table the proxy resolves to.
// AFTER — database/migrations/2025_xx_xx_add_reconciliation_indexes.php
public function up(): void
{
Schema::table('transactions', function (Blueprint $table) {
$table->index(['customer_id', 'created_at']);
$table->index('status');
});
Schema::table('affiliate_ledger_entries', function (Blueprint $table) {
$table->index(['affiliate_id', 'created_at']);
$table->index('payout_batch_id');
});
Schema::table('affiliate_payout_batch_items', function (Blueprint $table) {
$table->index(['payout_batch_id', 'status']);
});
Schema::table('affiliate_compliance_profiles', function (Blueprint $table) {
$table->index(['affiliate_id', 'status']);
});
Schema::table('customer_vaults', function (Blueprint $table) {
$table->unique(['gateway', 'vault_token']);
});
}
Two decisions mattered more than the indexes themselves: the payout pipeline is batch-first, not affiliate-first (AffiliatePayoutBatch owns a period and a status, each AffiliatePayoutBatchItem belongs to exactly one batch and one affiliate), and compliance is its own first-class table, not a boolean bolted onto the affiliate record — a hold placed in March is still fully auditable in October.
Part 3 — The Engineering Evolution: From Hand-Coded to Autonomous
Early on: hand-built, by necessity. Everything in Part 1 was audited and rewritten by hand, starting in February 2023 — reading the outsourced agency's code line by line, reproducing bugs locally, writing the fix, writing the test, shipping it. No AI tooling. This phase is what gave me the ground-truth domain understanding every later phase built on.
The middle stretch: GitHub Copilot as a velocity multiplier. Once the core abstractions existed, Copilot earned its keep on the repetitive surface area around them — boilerplate migrations, Eloquent relationships, PHPUnit scaffolding, admin Blade structure. Worth being precise here: inline Copilot suggestions don't show up as a separate author in git history — they land as part of whatever commit you make, so there's no clean "Copilot commits" count to point to for this stretch the way there is for the later agentic phase below. It never touched the parts that required judgment calls; it meaningfully compressed everything downstream of them.
The final stretch: a genuine, verifiable shift to agentic engineering. This part isn't just a claim — it's in the repository's own commit history, under distinct authors instead of folded into mine: GitHub's autonomous coding agent (copilot-swe-agent) has 127 commits of its own in the repo, and Cursor's agent shows up as a direct committer too. Cursor and Claude Code running as actual collaborators against the full repository context, not just the current file, is what let me run full-codebase refactors as a reviewed, test-gated change set instead of a multi-day manual migration; stand up entirely new operational surfaces fast enough that they shipped as a matter of course; and push every credential, model choice, and feature toggle out of .env/hardcoded config into a database-backed settings layer editable from an admin panel.
Part 4 — The Strategic AI Hand-off
The platform, and the operational tooling around it, got robust enough — and the AI-assisted workflows seamless enough — that Dr. Schmidt didn't need to keep a full-time engineer on staff to keep it evolving. That was the explicit target of the last stretch of this engagement, and it's not just a framing choice on my part: by the time I stepped back, Dr. Schmidt had well over a hundred of his own commits directly in the repository, written under his own name — not me committing on his behalf, him actually shipping changes himself.
That's the real measure of the hand-off working: every configuration surface a non-technical owner would eventually need lives in an admin panel; every background process runs as a queued, named, independently restartable job; and the AI tooling that accelerated the final stretch became Dr. Schmidt's, not just mine, because the architecture underneath it gave an agentic coding assistant — and him — a clean, well-bounded system to reason about.
I phased off the project once that was true, not before. Handing off a system genuinely better positioned to keep improving without me than it was when I arrived is exactly the outcome I was building toward — and exactly the kind of engagement I'm looking for next.
Need a legacy Laravel/commerce codebase audited, a payment or affiliate system re-engineered, or autonomous AI agents wired into how your team ships? This is the work I take on. If that's where your team is, reach out.
Comments
No comments yet — be the first to share your thoughts.
Leave a comment
Your comment will be reviewed before it appears publicly.