HEX
Server: LiteSpeed
System: Linux s1049.use1.mysecurecloudhost.com 4.18.0-477.27.2.lve.el8.x86_64 #1 SMP Wed Oct 11 12:32:56 UTC 2023 x86_64
User: xedaptot (3356)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: /home/xedaptot/be.naniguide.com/app/Cashier/Factories/PaymentIntentFactory.php
<?php

namespace App\Cashier\Factories;

use App\Model\Invoice;
use App\Model\PaymentGateway;
use App\Model\PaymentIntent as PaymentIntentModel;
use App\Model\PaymentMethod;
use App\Cashier\DTO\PaymentIntent as PaymentIntentDto;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;

/**
 * Builds a PaymentIntent — both the DB row and the cashier DTO.
 *
 * Race-safe: holds a row lock on the invoice while deciding whether to reuse
 * an existing in-flight intent or create a new one. Prevents multi-tab
 * double-pay (Section 12.5 of the design doc).
 *
 * Behavior:
 *   - Existing pending/requires_action intent for SAME gateway → reuse (return its DTO).
 *   - Existing in-flight intent for DIFFERENT gateway → cancel it, create new.
 *   - No in-flight intent → create new.
 *
 * The lock is released on terminal transitions by CheckoutHandler.
 */
class PaymentIntentFactory
{
    /**
     * @param array|null $subSpec  ['remote_plan_id' => 'price_xxx'] for subscription, null for one-off
     * @param PaymentMethod|null $paymentMethod  Saved card being used (auto-charge path).
     *                                            When set, intent records both FK + immutable snapshot
     *                                            (see docs/payment-order-plan-subscription-saas/PAYMENT-COMPREHENSIVE-DESIGN.md §14).
     */
    public function createFromInvoice(
        Invoice $invoice,
        PaymentGateway $gateway,
        ?array $subSpec = null,
        ?PaymentMethod $paymentMethod = null
    ): PaymentIntentDto {
        return DB::transaction(function () use ($invoice, $gateway, $subSpec, $paymentMethod) {
            // Lock the invoice row so concurrent checkout requests serialize here.
            $locked = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail();

            // Reuse path: existing in-flight intent on the same gateway?
            if ($locked->processing_intent_id) {
                $existing = PaymentIntentModel::find($locked->processing_intent_id);

                if ($existing && !$existing->isTerminal()) {
                    if ($existing->payment_gateway_id === $gateway->id) {
                        $existing->load(['invoice.customer', 'paymentGateway']);
                        return $existing->toDto();
                    }

                    // Different gateway — supersede the old in-flight intent.
                    $existing->update(['status' => PaymentIntentModel::STATUS_CANCELLED]);
                }
            }

            // Create fresh intent.
            $metadata = [];
            if ($subSpec && isset($subSpec['remote_plan_id'])) {
                $metadata['remote_plan_id'] = $subSpec['remote_plan_id'];
            }

            $model = PaymentIntentModel::create([
                'uid'                     => (string) Str::uuid(),
                'invoice_id'              => $locked->id,
                'payment_gateway_id'      => $gateway->id,
                'payment_method_id'       => $paymentMethod?->id,
                'payment_method_snapshot' => $paymentMethod ? self::snapshotFor($paymentMethod) : null,
                'amount'                  => $locked->total(),
                'currency'                => $locked->getCurrencyCode(),
                'description'             => trans('messages.pay_invoice', ['id' => $locked->uid]),
                'status'                  => PaymentIntentModel::STATUS_PENDING,
                'metadata'                => $metadata,
            ]);

            // Set the lock pointer.
            $locked->processing_intent_id = $model->id;
            $locked->save();

            $model->load(['invoice.customer', 'paymentGateway']);
            return $model->toDto();
        });
    }

    /**
     * Build the immutable label persisted in `payment_intents.payment_method_snapshot`.
     * Survives PaymentMethod deletion — see docs/payment-order-plan-subscription-saas/PAYMENT-COMPREHENSIVE-DESIGN.md §14.
     */
    public static function snapshotFor(PaymentMethod $pm): string
    {
        try {
            $title = $pm->getMethodTitle();
        } catch (\Throwable $e) {
            // Gateway service unavailable (rare — credentials revoked / type unknown).
            // Fall back to a stable identifier so the snapshot still distinguishes
            // "intent did use a saved PM" from "no PM ever used".
            \Log::warning('payment-method snapshot fallback', [
                'pm_id' => $pm->id,
                'gateway_type' => $pm->paymentGateway?->type,
                'error' => $e->getMessage(),
            ]);
            $title = "Payment method #{$pm->id}";
        }
        return mb_substr((string) $title, 0, 255);
    }
}