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);
}
}