Changeset View
Changeset View
Standalone View
Standalone View
src/app/Providers/Payment/Stripe.php
<?php | <?php | ||||
namespace App\Providers\Payment; | namespace App\Providers\Payment; | ||||
use App\Payment; | use App\Payment; | ||||
use App\Utils; | use App\Utils; | ||||
use App\Wallet; | use App\Wallet; | ||||
use App\WalletSetting; | use App\WalletSetting; | ||||
use Illuminate\Support\Facades\DB; | |||||
use Illuminate\Support\Facades\Request; | |||||
use Stripe as StripeAPI; | use Stripe as StripeAPI; | ||||
class Stripe extends \App\Providers\PaymentProvider | class Stripe extends \App\Providers\PaymentProvider | ||||
{ | { | ||||
/** | /** | ||||
* Class constructor. | * Class constructor. | ||||
*/ | */ | ||||
public function __construct() | public function __construct() | ||||
▲ Show 20 Lines • Show All 51 Lines • ▼ Show 20 Lines | public function createMandate(Wallet $wallet, array $payment): ?array | ||||
'success_url' => \url('/wallet'), // required | 'success_url' => \url('/wallet'), // required | ||||
'payment_method_types' => ['card'], // required | 'payment_method_types' => ['card'], // required | ||||
'locale' => 'en', | 'locale' => 'en', | ||||
'mode' => 'setup', | 'mode' => 'setup', | ||||
]; | ]; | ||||
$session = StripeAPI\Checkout\Session::create($request); | $session = StripeAPI\Checkout\Session::create($request); | ||||
$payment = [ | |||||
'id' => $session->setup_intent, | |||||
'type' => self::TYPE_MANDATE, | |||||
]; | |||||
$this->storePayment($payment, $wallet->id); | |||||
return [ | return [ | ||||
'id' => $session->id, | 'id' => $session->id, | ||||
]; | ]; | ||||
} | } | ||||
/** | /** | ||||
* Revoke the auto-payment mandate. | * Revoke the auto-payment mandate. | ||||
* | * | ||||
Show All 39 Lines | public function getMandate(Wallet $wallet): ?array | ||||
} | } | ||||
$pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); | $pm = StripeAPI\PaymentMethod::retrieve($mandate->payment_method); | ||||
$result = [ | $result = [ | ||||
'id' => $mandate->id, | 'id' => $mandate->id, | ||||
'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', | 'isPending' => $mandate->status != 'succeeded' && $mandate->status != 'canceled', | ||||
'isValid' => $mandate->status == 'succeeded', | 'isValid' => $mandate->status == 'succeeded', | ||||
'method' => self::paymentMethod($pm, 'Unknown method') | |||||
]; | ]; | ||||
switch ($pm->type) { | |||||
case 'card': | |||||
// TODO: card number | |||||
$result['method'] = \sprintf( | |||||
'%s (**** **** **** %s)', | |||||
// @phpstan-ignore-next-line | |||||
\ucfirst($pm->card->brand) ?: 'Card', | |||||
// @phpstan-ignore-next-line | |||||
$pm->card->last4 | |||||
); | |||||
break; | |||||
default: | |||||
$result['method'] = 'Unknown method'; | |||||
} | |||||
return $result; | return $result; | ||||
} | } | ||||
/** | /** | ||||
* Get a provider name | * Get a provider name | ||||
* | * | ||||
* @return string Provider name | * @return string Provider name | ||||
*/ | */ | ||||
Show All 38 Lines | public function payment(Wallet $wallet, array $payment): ?array | ||||
'quantity' => 1, | 'quantity' => 1, | ||||
] | ] | ||||
] | ] | ||||
]; | ]; | ||||
$session = StripeAPI\Checkout\Session::create($request); | $session = StripeAPI\Checkout\Session::create($request); | ||||
// Store the payment reference in database | // Store the payment reference in database | ||||
$payment['status'] = self::STATUS_OPEN; | |||||
$payment['id'] = $session->payment_intent; | $payment['id'] = $session->payment_intent; | ||||
self::storePayment($payment, $wallet->id); | $this->storePayment($payment, $wallet->id); | ||||
return [ | return [ | ||||
'id' => $session->id, | 'id' => $session->id, | ||||
]; | ]; | ||||
} | } | ||||
/** | /** | ||||
* Create a new automatic payment operation. | * Create a new automatic payment operation. | ||||
Show All 22 Lines | protected function paymentRecurring(Wallet $wallet, array $payment): ?array | ||||
'receipt_email' => $wallet->owner->email, | 'receipt_email' => $wallet->owner->email, | ||||
'customer' => $mandate->customer, | 'customer' => $mandate->customer, | ||||
'payment_method' => $mandate->payment_method, | 'payment_method' => $mandate->payment_method, | ||||
]; | ]; | ||||
$intent = StripeAPI\PaymentIntent::create($request); | $intent = StripeAPI\PaymentIntent::create($request); | ||||
// Store the payment reference in database | // Store the payment reference in database | ||||
$payment['status'] = self::STATUS_OPEN; | |||||
$payment['id'] = $intent->id; | $payment['id'] = $intent->id; | ||||
self::storePayment($payment, $wallet->id); | $this->storePayment($payment, $wallet->id); | ||||
return [ | return [ | ||||
'id' => $payment['id'], | 'id' => $payment['id'], | ||||
]; | ]; | ||||
} | } | ||||
/** | /** | ||||
* Update payment status (and balance). | * Update payment status (and balance). | ||||
* | * | ||||
* @return int HTTP response code | * @return int HTTP response code | ||||
*/ | */ | ||||
public function webhook(): int | public function webhook(): int | ||||
{ | { | ||||
$payload = file_get_contents('php://input'); | // We cannot just use php://input as it's already "emptied" by the framework | ||||
$sig_header = $_SERVER['HTTP_STRIPE_SIGNATURE']; | // $payload = file_get_contents('php://input'); | ||||
$request = Request::instance(); | |||||
$payload = $request->getContent(); | |||||
$sig_header = $request->header('Stripe-Signature'); | |||||
// Parse and validate the input | // Parse and validate the input | ||||
try { | try { | ||||
$event = StripeAPI\Webhook::constructEvent( | $event = StripeAPI\Webhook::constructEvent( | ||||
$payload, | $payload, | ||||
$sig_header, | $sig_header, | ||||
\config('services.stripe.webhook_secret') | \config('services.stripe.webhook_secret') | ||||
); | ); | ||||
} catch (\UnexpectedValueException $e) { | } catch (\Exception $e) { | ||||
// Invalid payload | // Invalid payload | ||||
return 400; | return 400; | ||||
} | } | ||||
switch ($event->type) { | switch ($event->type) { | ||||
case StripeAPI\Event::PAYMENT_INTENT_CANCELED: | case StripeAPI\Event::PAYMENT_INTENT_CANCELED: | ||||
case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED: | case StripeAPI\Event::PAYMENT_INTENT_PAYMENT_FAILED: | ||||
case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED: | case StripeAPI\Event::PAYMENT_INTENT_SUCCEEDED: | ||||
$intent = $event->data->object; // @phpstan-ignore-line | $intent = $event->data->object; // @phpstan-ignore-line | ||||
$payment = Payment::find($intent->id); | $payment = Payment::find($intent->id); | ||||
if (empty($payment) || $payment->type == self::TYPE_MANDATE) { | |||||
return 404; | |||||
} | |||||
switch ($intent->status) { | switch ($intent->status) { | ||||
case StripeAPI\PaymentIntent::STATUS_CANCELED: | case StripeAPI\PaymentIntent::STATUS_CANCELED: | ||||
$status = self::STATUS_CANCELED; | $status = self::STATUS_CANCELED; | ||||
break; | break; | ||||
case StripeAPI\PaymentIntent::STATUS_SUCCEEDED: | case StripeAPI\PaymentIntent::STATUS_SUCCEEDED: | ||||
$status = self::STATUS_PAID; | $status = self::STATUS_PAID; | ||||
break; | break; | ||||
default: | default: | ||||
$status = self::STATUS_PENDING; | $status = self::STATUS_FAILED; | ||||
} | } | ||||
DB::beginTransaction(); | |||||
if ($status == self::STATUS_PAID) { | if ($status == self::STATUS_PAID) { | ||||
// Update the balance, if it wasn't already | // Update the balance, if it wasn't already | ||||
if ($payment->status != self::STATUS_PAID) { | if ($payment->status != self::STATUS_PAID) { | ||||
$payment->wallet->credit($payment->amount); | $this->creditPayment($payment, $intent); | ||||
} | } | ||||
} elseif (!empty($intent->last_payment_error)) { | } else { | ||||
if (!empty($intent->last_payment_error)) { | |||||
// See https://stripe.com/docs/error-codes for more info | // See https://stripe.com/docs/error-codes for more info | ||||
\Log::info(sprintf( | \Log::info(sprintf( | ||||
'Stripe payment failed (%s): %s', | 'Stripe payment failed (%s): %s', | ||||
$payment->id, | $payment->id, | ||||
json_encode($intent->last_payment_error) | json_encode($intent->last_payment_error) | ||||
)); | )); | ||||
} | } | ||||
} | |||||
if ($payment->status != self::STATUS_PAID) { | if ($payment->status != self::STATUS_PAID) { | ||||
$payment->status = $status; | $payment->status = $status; | ||||
$payment->save(); | $payment->save(); | ||||
if ($status != self::STATUS_CANCELED && $payment->type == self::TYPE_RECURRING) { | |||||
// Disable the mandate | |||||
if ($status == self::STATUS_FAILED) { | |||||
$payment->wallet->setSetting('mandate_disabled', 1); | |||||
} | } | ||||
// Notify the user | |||||
\App\Jobs\PaymentEmail::dispatch($payment); | |||||
} | |||||
} | |||||
DB::commit(); | |||||
break; | break; | ||||
case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: | case StripeAPI\Event::SETUP_INTENT_SUCCEEDED: | ||||
case StripeAPI\Event::SETUP_INTENT_SETUP_FAILED: | |||||
case StripeAPI\Event::SETUP_INTENT_CANCELED: | |||||
$intent = $event->data->object; // @phpstan-ignore-line | $intent = $event->data->object; // @phpstan-ignore-line | ||||
$payment = Payment::find($intent->id); | |||||
if (empty($payment) || $payment->type != self::TYPE_MANDATE) { | |||||
return 404; | |||||
} | |||||
// Find the wallet | switch ($intent->status) { | ||||
// TODO: This query is potentially slow, we should find another way | case StripeAPI\SetupIntent::STATUS_CANCELED: | ||||
// Maybe use payment/transactions table to store the reference | $status = self::STATUS_CANCELED; | ||||
$setting = WalletSetting::where('key', 'stripe_id') | break; | ||||
->where('value', $intent->customer)->first(); | case StripeAPI\SetupIntent::STATUS_SUCCEEDED: | ||||
$status = self::STATUS_PAID; | |||||
break; | |||||
default: | |||||
$status = self::STATUS_FAILED; | |||||
} | |||||
if ($setting) { | if ($status == self::STATUS_PAID) { | ||||
$setting->wallet->setSetting('stripe_mandate_id', $intent->id); | $payment->wallet->setSetting('stripe_mandate_id', $intent->id); | ||||
} | } | ||||
$payment->status = $status; | |||||
$payment->save(); | |||||
break; | break; | ||||
default: | default: | ||||
\Log::debug("Unhandled Stripe event: " . var_export($payload, true)); | \Log::debug("Unhandled Stripe event: " . var_export($payload, true)); | ||||
break; | break; | ||||
} | } | ||||
return 200; | return 200; | ||||
Show All 39 Lines | protected static function stripeMandate(Wallet $wallet) | ||||
if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { | if ($mandate_id = $wallet->getSetting('stripe_mandate_id')) { | ||||
$mandate = StripeAPI\SetupIntent::retrieve($mandate_id); | $mandate = StripeAPI\SetupIntent::retrieve($mandate_id); | ||||
// @phpstan-ignore-next-line | // @phpstan-ignore-next-line | ||||
if ($mandate && $mandate->status != 'canceled') { | if ($mandate && $mandate->status != 'canceled') { | ||||
return $mandate; | return $mandate; | ||||
} | } | ||||
} | } | ||||
} | } | ||||
/** | |||||
* Apply the successful payment's pecunia to the wallet | |||||
*/ | |||||
protected static function creditPayment(Payment $payment, $intent) | |||||
{ | |||||
$method = 'Stripe'; | |||||
// Extract the payment method for transaction description | |||||
if ( | |||||
!empty($intent->charges) | |||||
&& ($charge = $intent->charges->data[0]) | |||||
&& ($pm = $charge->payment_method_details) | |||||
) { | |||||
$method = self::paymentMethod($pm); | |||||
} | |||||
// TODO: Localization? | |||||
$description = $payment->type == self::TYPE_RECURRING ? 'Auto-payment' : 'Payment'; | |||||
$description .= " transaction {$payment->id} using {$method}"; | |||||
$payment->wallet->credit($payment->amount, $description); | |||||
// Unlock the disabled auto-payment mandate | |||||
if ($payment->wallet->balance >= 0) { | |||||
$payment->wallet->setSetting('mandate_disabled', null); | |||||
} | |||||
} | |||||
/** | |||||
* Extract payment method description from Stripe payment details | |||||
*/ | |||||
protected static function paymentMethod($details, $default = ''): string | |||||
{ | |||||
switch ($details->type) { | |||||
case 'card': | |||||
// TODO: card number | |||||
return \sprintf( | |||||
'%s (**** **** **** %s)', | |||||
// @phpstan-ignore-next-line | |||||
\ucfirst($details->card->brand) ?: 'Card', | |||||
// @phpstan-ignore-next-line | |||||
$details->card->last4 | |||||
); | |||||
} | |||||
return $default; | |||||
} | |||||
} | } |