AW
Case study 02 · MONISCOPE

Multi-processor payment abstraction

One Action class — ProcessAutopayCharge — routes monthly recurring charges across four processors. Per-rental distributed locks prevent double-charges, every webhook is signed and idempotent, and every failure path creates a staff task instead of vanishing into a log.

Wired against Stripe, Authorize.Net, and Square sandboxes — no real cards have been charged in production yet. Native ACH talks to a real bank-rail integration on a sandbox endpoint.
The four processors

One Action, four sub-handlers, one return shape

Every sub-handler returns 'charged' | 'skipped' | 'failed'. The orchestrator never branches on processor — it just records the outcome. Adding a fifth processor is one new private method, not a refactor.

ProcessorMethodStored credentialSurcharge
Stripestripe_cardStripe pm_… payment-method IDper-method %
Authorize.Netauthorize_net_cardCIM customer profile IDper-method %
Squaresquare_cardSquare customer + saved card IDper-method %
Native ACHnative_achVerified AchAccount rowflat fee (toggled)
The engineering "why"

Six things this solves that "just call Stripe" doesn't

1

Per-rental distributed locking

Two queue workers (or admin + cron) could otherwise both pass the "already paid?" check before either inserts. Cache::lock("autopay:rental:{$id}", 120) forces exactly one charge per rental per cycle.

snippet 01
2

Processor-routed dispatch

A single switch on $rental->payment_method picks the right private handler. Caller doesn't care which processor ran.

snippet 02
3

Three-flavor signature verification

Stripe's Webhook::constructEvent, Authorize.Net's hash_hmac('sha512', …, hex2bin($key)), Square's HMAC-SHA256 over notificationUrl + body. All three timing-safe.

in full case study
4

Idempotent webhook processing

Every webhook logs to webhook_logs, then deduplicates by event ID before any DB write. The Stripe handler probes 3 tables to find the matching record.

snippet 03
5

ACH return-code state machine

R01 (NSF) → retry queue. R02–R04 (account closed) → suspend. R07/R08/R29 (auth revoked) → halt all future debits. R10 → flag for review. Each path triggers a different staff task.

in full case study
6

Atomic settlement guard

UPDATE … WHERE status IN ('initiated','processing') + affected-row check. Two workers race? Exactly one commits, the other gets false and silently skips — no exception.

in full case study
Architecture

Where the call goes

Schedule: 8:30 daily · America/Chicago
payments:process-autopay
for each rental with autopay=1 AND payment_due ≤ today: ProcessAutopayCharge::run($rental) charged | skipped | failed
per-rental distributed lock
Cache::lock("autopay:rental:{id}", 120) ← prevents double-charges
match($rental->payment_method)
four sub-handlers, one return shape
native_ach
AchService::initiateDebit
authorize_net_card
AuthorizeNetService::chargeProfile
square_card
SquareService::chargeSavedCard
stripe_card
ChargeStripeCard::run
On success
RecordPayment::run([…])
On failure
PaymentFailed::dispatch()
listeners create staff task, notify customer, log audit
Webhooks (async, processor → us)
/webhooks/stripe · /webhooks/authorize-net · /webhooks/square
1.
verify HMAC signature (timing-safe)
2.
dedupe by event_id in webhook_logs
3.
match() event type → handler
4.
handler updates Payment | Rental | Order, dispatches events
5.
errors caught + logged to webhook_logs.error
The Code · how it flows

Three snippets, in execution order

Lock the rental → route to the right processor → on success, processor calls back via webhook → dedupe + match the record. Real excerpts from app/Actions/Payment/ProcessAutopayCharge.php and app/Http/Controllers/StripeWebhookController.php.

Step 1 · Before any work happens

Per-rental distributed lock + re-check inside the lock

The lock alone isn't enough — a previous run could have already charged this billing period and released. The "already paid?" check inside the lock is the source of truth; the lock just serializes the attempt.

ProcessAutopayCharge.php · run() + processWithLock()php
public static function run(Rental $rental): string
{
    $user = $rental->user;
    if (!$user) {
        return 'skipped';
    }

    // Per-rental lock — 120s TTL is generous; a charge usually takes <2s.
    // Auto-released on TTL even if the worker dies mid-charge.
    $lock = Cache::lock("autopay:rental:{$rental->id}", 120);

    if (!$lock->get()) {
        Log::info("Autopay: skipping rental #{$rental->id} — already being processed");
        return 'skipped';
    }

    try {
        return static::processWithLock($rental, $user);
    } finally {
        $lock->release();  // always release, even on exception
    }
}

private static function processWithLock(Rental $rental, $user): string
{
    // Inside the lock, re-check the DB. The lock alone is not enough —
    // a previous run could have already charged this billing period and
    // released. The "already paid?" check is the source of truth; the
    // lock just serializes the *attempt*.
    $alreadyPaid = Payment::where('rental_id', $rental->id)
        ->where('due_date', $rental->payment_due_date)
        ->whereIn('status', ['completed', 'pending'])
        ->exists();

    if ($alreadyPaid) {
        return 'skipped';
    }

    $amount  = (float) $rental->monthly_rate;
    $lateFee = $rental->late_fee_applied ? (float) $rental->late_fee_amount : 0;

    // Dispatch to the right processor (next snippet)...
    return static::dispatchByMethod($rental, $user, $amount, $lateFee);
}
Step 2 · Pick the right processor

Routing dispatch + a representative sub-handler

Every sub-handler returns the same shape. Adding a fifth processor means one new private method and one arm in the switch. Below: the router, then Authorize.Net's full handler with surcharge calc, success path, and failure path.

ProcessAutopayCharge.php · dispatch + processAuthorizeNetAutopay()php
// ── Router ──────────────────────────────────────────────────────
private static function dispatchByMethod(Rental $rental, $user, float $amount, float $lateFee): string
{
    if ($rental->payment_method === 'native_ach') {
        return static::processAchAutopay($rental, $user, $amount, $lateFee);
    }
    if ($rental->payment_method === 'authorize_net_card') {
        return static::processAuthorizeNetAutopay($rental, $user, $amount, $lateFee);
    }
    if ($rental->payment_method === 'square_card') {
        return static::processSquareAutopay($rental, $user, $amount, $lateFee);
    }
    // Default: Stripe (the most-used path)
    return static::processStripeAutopay($rental, $user, $amount, $lateFee);
}

// ── Representative sub-handler: Authorize.Net ───────────────────
private static function processAuthorizeNetAutopay(Rental $rental, $user, float $amount, float $lateFee): string
{
    if (!$user->anet_customer_profile_id || !AuthorizeNetService::isConfigured()) {
        // No profile / processor disabled — surface to staff, don't silently skip.
        CreateStaffTask::run(
            title: "Authorize.Net Autopay failed — Unit {$rental->unit_number} ({$user->name})",
            description: "No Authorize.Net profile on file or processor not configured.",
            relatedUserId: $user->id,
        );
        return 'skipped';
    }

    $total          = $amount + $lateFee;
    $surcharge      = Payment::calculateSurcharge($total, 'authorize_net_card');
    $surchargeCents = (int) round($surcharge * 100);

    try {
        $txnId = AuthorizeNetService::chargeProfile(
            $user,
            '', // empty = default payment profile
            (int) round($total * 100) + $surchargeCents,
            "Autopay — Unit {$rental->unit_number}",
        );

        RecordPayment::run([
            'rental_id'           => $rental->id,
            'user_id'             => $user->id,
            'amount'              => $amount,
            'late_fee'            => $lateFee,
            'processing_fee'      => $surcharge,
            'paid_at'             => now(),
            'status'              => 'completed',
            'payment_method'      => 'authorize_net_card',
            'anet_transaction_id' => $txnId,
        ]);

        return 'charged';

    } catch (\Exception $e) {
        // Failure path: record the failed payment, schedule a retry, fire event.
        $payment = Payment::create([
            'rental_id'      => $rental->id,
            'user_id'        => $user->id,
            'amount'         => $amount,
            'late_fee'       => $lateFee,
            'status'         => 'failed',
            'payment_method' => 'authorize_net_card',
            'notes'          => "Authorize.Net autopay failed: {$e->getMessage()}",
        ]);

        // Configurable retry intervals: [1, 3, 7, 14] days by default.
        $retryIntervals = config('facility.payments.retry_intervals', [1, 3, 7, 14]);
        $payment->update(['next_retry_at' => now()->addDays($retryIntervals[0])]);

        // PaymentFailed triggers ~6 listeners: notice, task, audit log, ...
        PaymentFailed::dispatch($payment, $rental, $user, $e->getMessage());

        return 'failed';
    }
}
Step 3 · When the processor calls back

Idempotent webhook + multi-table record probe

Stripe (and every other major processor) re-delivers events if they don't get a fast 200. We dedupe by webhook_logs.event_id before any work happens, and a payment_intent.succeeded probes Payment, Rental, and Order in priority order — a real autopay span all three once MONISCOPE goes live.

StripeWebhookController.phpphp
public function handleWebhook(Request $request)
{
    // (signature verification above — Stripe's Webhook::constructEvent
    //  with tolerance window. Throws on bad sig, before we get here.)

    // ── Dedupe by event ID — Stripe retries up to 3 days ──────────
    $eventId = $event->id ?? null;
    if (
        $eventId &&
        WebhookLog::where('event_id', $eventId)->where('status', 'processed')->exists()
    ) {
        return response('Already processed', 200);
    }

    // ── Log first — even if the handler throws, we have evidence ──
    $log = WebhookLog::create([
        'provider'   => 'stripe',
        'event_id'   => $eventId,
        'event_type' => $event->type ?? 'unknown',
        'payload'    => json_decode($payload, true) ?: [],
        'status'     => 'received',
    ]);

    // ── Route to handler ──────────────────────────────────────────
    try {
        match ($event->type) {
            'payment_intent.succeeded'      => $this->handlePaymentIntentSucceeded($event->data->object, $log),
            'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event->data->object, $log),
            'charge.refunded'               => $this->handleChargeRefunded($event->data->object, $log),
            default                         => $log->update(['status' => 'ignored']),
        };
    } catch (\Exception $e) {
        $log->update(['status' => 'failed', 'error' => $e->getMessage()]);
    }

    return response('OK', 200);
}

// ── The matching record could live in 3 different tables ────────
private function handlePaymentIntentSucceeded(object $paymentIntent, WebhookLog $log): void
{
    $piId = $paymentIntent->id;

    // 1) Payment records (autopay / billing modal)
    $payment = Payment::where('stripe_payment_intent_id', $piId)
        ->where('status', '!=', 'completed')
        ->first();
    if ($payment) {
        $payment->update(['status' => 'completed', 'paid_at' => now()]);
        $payment->rental?->advanceBillingState();   // state machine on Rental
        $log->update(['status' => 'processed', 'notes' => "Payment #{$payment->id} confirmed"]);
        return;
    }

    // 2) Rental records (initial checkout — no Payment row yet at PI creation)
    $rental = Rental::where('stripe_payment_intent', $piId)->where('payment_status', '!=', 'completed')->first();
    if ($rental) {
        $rental->update(['payment_status' => 'completed']);
        $log->update(['status' => 'processed', 'notes' => "Rental #{$rental->id} confirmed"]);
        return;
    }

    // 3) Order records (storefront / magic-link payments)
    $order = Order::where('stripe_payment_intent', $piId)->where('status', '!=', 'completed')->first();
    if ($order) {
        $order->update(['status' => 'completed']);
        $log->update(['status' => 'processed', 'notes' => "Order #{$order->id} confirmed"]);
        return;
    }

    // No match — log for investigation. Don't throw — Stripe will keep
    // retrying for 3 days, and we don't want them to.
    $log->update(['status' => 'no_match', 'notes' => "No matching record for PI {$piId}"]);
}

Source

Excerpts from app/Actions/Payment/ProcessAutopayCharge.php and app/Http/Controllers/StripeWebhookController.php in MONISCOPE (pre-launch — wired against processor sandboxes, no real cards charged). The full build also includes signature verification across all three processors, the ACH return-code state machine, and the atomic settlement guard for native ACH — happy to walk through any of them.