Changeset View
Changeset View
Standalone View
Standalone View
src/app/Providers/Payment/Mollie.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 Illuminate\Support\Facades\DB; | |||||
class Mollie extends \App\Providers\PaymentProvider | class Mollie extends \App\Providers\PaymentProvider | ||||
{ | { | ||||
/** | /** | ||||
* Get a link to the customer in the provider's control panel | * Get a link to the customer in the provider's control panel | ||||
* | * | ||||
* @param \App\Wallet $wallet The wallet | * @param \App\Wallet $wallet The wallet | ||||
* | * | ||||
▲ Show 20 Lines • Show All 96 Lines • ▼ Show 20 Lines | public function getMandate(Wallet $wallet): ?array | ||||
if (empty($mandate)) { | if (empty($mandate)) { | ||||
return null; | return null; | ||||
} | } | ||||
$result = [ | $result = [ | ||||
'id' => $mandate->id, | 'id' => $mandate->id, | ||||
'isPending' => $mandate->isPending(), | 'isPending' => $mandate->isPending(), | ||||
'isValid' => $mandate->isValid(), | 'isValid' => $mandate->isValid(), | ||||
'method' => self::paymentMethod($mandate, 'Unknown method') | |||||
]; | ]; | ||||
$details = $mandate->details; | |||||
// Mollie supports 3 methods here | |||||
switch ($mandate->method) { | |||||
case 'creditcard': | |||||
// If the customer started, but never finished the 'first' payment | |||||
// card details will be empty, and mandate will be 'pending'. | |||||
if (empty($details->cardNumber)) { | |||||
$result['method'] = 'Credit Card'; | |||||
} else { | |||||
$result['method'] = sprintf( | |||||
'%s (**** **** **** %s)', | |||||
$details->cardLabel ?: 'Card', // @phpstan-ignore-line | |||||
$details->cardNumber | |||||
); | |||||
} | |||||
break; | |||||
case 'directdebit': | |||||
$result['method'] = sprintf( | |||||
'Direct Debit (%s)', | |||||
$details->customerAccount | |||||
); | |||||
break; | |||||
case 'paypal': | |||||
$result['method'] = sprintf('PayPal (%s)', $details->consumerAccount); | |||||
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 13 Lines | class Mollie extends \App\Providers\PaymentProvider | ||||
* - description: Operation desc. | * - description: Operation desc. | ||||
* | * | ||||
* @return array Provider payment data: | * @return array Provider payment data: | ||||
* - id: Operation identifier | * - id: Operation identifier | ||||
* - redirectUrl: the location to redirect to | * - redirectUrl: the location to redirect to | ||||
*/ | */ | ||||
public function payment(Wallet $wallet, array $payment): ?array | public function payment(Wallet $wallet, array $payment): ?array | ||||
{ | { | ||||
if ($payment['type'] == self::TYPE_RECURRING) { | |||||
return $this->paymentRecurring($wallet, $payment); | |||||
} | |||||
// Register the user in Mollie, if not yet done | // Register the user in Mollie, if not yet done | ||||
$customer_id = self::mollieCustomerId($wallet); | $customer_id = self::mollieCustomerId($wallet); | ||||
// Note: Required fields: description, amount/currency, amount/value | // Note: Required fields: description, amount/currency, amount/value | ||||
$request = [ | $request = [ | ||||
'amount' => [ | 'amount' => [ | ||||
'currency' => $payment['currency'], | 'currency' => $payment['currency'], | ||||
// a number with two decimals is required | // a number with two decimals is required | ||||
'value' => sprintf('%.2f', $payment['amount'] / 100), | 'value' => sprintf('%.2f', $payment['amount'] / 100), | ||||
], | ], | ||||
'customerId' => $customer_id, | 'customerId' => $customer_id, | ||||
'sequenceType' => $payment['type'], | 'sequenceType' => $payment['type'], | ||||
'description' => $payment['description'], | 'description' => $payment['description'], | ||||
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), | 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), | ||||
'locale' => 'en_US', | 'locale' => 'en_US', | ||||
// 'method' => 'creditcard', | // 'method' => 'creditcard', | ||||
'redirectUrl' => \url('/wallet') // required for non-recurring payments | |||||
]; | ]; | ||||
if ($payment['type'] == self::TYPE_RECURRING) { | // TODO: Additional payment parameters for better fraud protection: | ||||
// billingEmail - for bank transfers, Przelewy24, but not creditcard | |||||
// billingAddress (it is a structured field not just text) | |||||
// Create the payment in Mollie | |||||
$response = mollie()->payments()->create($request); | |||||
// Store the payment reference in database | |||||
$payment['status'] = $response->status; | |||||
$payment['id'] = $response->id; | |||||
$this->storePayment($payment, $wallet->id); | |||||
return [ | |||||
'id' => $payment['id'], | |||||
'redirectUrl' => $response->getCheckoutUrl(), | |||||
]; | |||||
} | |||||
/** | |||||
* Create a new automatic payment operation. | |||||
* | |||||
* @param \App\Wallet $wallet The wallet | |||||
* @param array $payment Payment data (see self::payment()) | |||||
* | |||||
* @return array Provider payment/session data: | |||||
* - id: Operation identifier | |||||
*/ | |||||
protected function paymentRecurring(Wallet $wallet, array $payment): ?array | |||||
{ | |||||
// Check if there's a valid mandate | // Check if there's a valid mandate | ||||
$mandate = self::mollieMandate($wallet); | $mandate = self::mollieMandate($wallet); | ||||
if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { | if (empty($mandate) || !$mandate->isValid() || $mandate->isPending()) { | ||||
return null; | return null; | ||||
} | } | ||||
$request['mandateId'] = $mandate->id; | $customer_id = self::mollieCustomerId($wallet); | ||||
} else { | |||||
// required for non-recurring payments | |||||
$request['redirectUrl'] = \url('/wallet'); | |||||
// TODO: Additional payment parameters for better fraud protection: | // Note: Required fields: description, amount/currency, amount/value | ||||
// billingEmail - for bank transfers, Przelewy24, but not creditcard | |||||
// billingAddress (it is a structured field not just text) | $request = [ | ||||
} | 'amount' => [ | ||||
'currency' => $payment['currency'], | |||||
// a number with two decimals is required | |||||
'value' => sprintf('%.2f', $payment['amount'] / 100), | |||||
], | |||||
'customerId' => $customer_id, | |||||
'sequenceType' => $payment['type'], | |||||
'description' => $payment['description'], | |||||
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), | |||||
'locale' => 'en_US', | |||||
// 'method' => 'creditcard', | |||||
'mandateId' => $mandate->id | |||||
]; | |||||
// Create the payment in Mollie | // Create the payment in Mollie | ||||
$response = mollie()->payments()->create($request); | $response = mollie()->payments()->create($request); | ||||
// Store the payment reference in database | // Store the payment reference in database | ||||
$payment['status'] = $response->status; | $payment['status'] = $response->status; | ||||
$payment['id'] = $response->id; | $payment['id'] = $response->id; | ||||
self::storePayment($payment, $wallet->id); | DB::beginTransaction(); | ||||
$payment = $this->storePayment($payment, $wallet->id); | |||||
// Mollie can return 'paid' status immediately, so we don't | |||||
// have to wait for the webhook. What's more, the webhook would ignore | |||||
// the payment because it will be marked as paid before the webhook. | |||||
// Let's handle paid status here too. | |||||
if ($response->isPaid()) { | |||||
self::creditPayment($payment, $response); | |||||
$notify = true; | |||||
} elseif ($response->isFailed()) { | |||||
vanmeeuwen: Typo. | |||||
// Note: I didn't find a way to get any description of the problem with a payment | |||||
\Log::info(sprintf('Mollie payment failed (%s)', $response->id)); | |||||
// Disable the mandate | |||||
$wallet->setSetting('mandate_disabled', 1); | |||||
$notify = true; | |||||
} | |||||
DB::commit(); | |||||
if (!empty($notify)) { | |||||
\App\Jobs\PaymentEmail::dispatch($payment); | |||||
} | |||||
return [ | return [ | ||||
'id' => $payment['id'], | 'id' => $payment['id'], | ||||
'redirectUrl' => $response->getCheckoutUrl(), | |||||
]; | ]; | ||||
} | } | ||||
/** | /** | ||||
* Update payment status (and balance). | * Update payment status (and balance). | ||||
* | * | ||||
* @return int HTTP response code | * @return int HTTP response code | ||||
*/ | */ | ||||
Show All 20 Lines | public function webhook(): int | ||||
return 200; | return 200; | ||||
} | } | ||||
if ($mollie_payment->isPaid()) { | if ($mollie_payment->isPaid()) { | ||||
if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) { | if (!$mollie_payment->hasRefunds() && !$mollie_payment->hasChargebacks()) { | ||||
// The payment is paid and isn't refunded or charged back. | // The payment is paid and isn't refunded or charged back. | ||||
// Update the balance, if it wasn't already | // Update the balance, if it wasn't already | ||||
if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { | if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { | ||||
$payment->wallet->credit($payment->amount); | $credit = true; | ||||
$notify = $payment->type == self::TYPE_RECURRING; | |||||
} | } | ||||
} elseif ($mollie_payment->hasRefunds()) { | } elseif ($mollie_payment->hasRefunds()) { | ||||
// The payment has been (partially) refunded. | // The payment has been (partially) refunded. | ||||
// The status of the payment is still "paid" | // The status of the payment is still "paid" | ||||
// TODO: Update balance | // TODO: Update balance | ||||
} elseif ($mollie_payment->hasChargebacks()) { | } elseif ($mollie_payment->hasChargebacks()) { | ||||
// The payment has been (partially) charged back. | // The payment has been (partially) charged back. | ||||
// The status of the payment is still "paid" | // The status of the payment is still "paid" | ||||
// TODO: Update balance | // TODO: Update balance | ||||
} | } | ||||
} elseif ($mollie_payment->isFailed()) { | } elseif ($mollie_payment->isFailed()) { | ||||
// Note: I didn't find a way to get any description of the problem with a payment | // Note: I didn't find a way to get any description of the problem with a payment | ||||
\Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); | \Log::info(sprintf('Mollie payment failed (%s)', $payment->id)); | ||||
// Disable the mandate | |||||
if ($payment->type == self::TYPE_RECURRING) { | |||||
$notify = true; | |||||
$payment->wallet->setSetting('mandate_disabled', 1); | |||||
} | |||||
} | } | ||||
DB::beginTransaction(); | |||||
// This is a sanity check, just in case the payment provider api | // This is a sanity check, just in case the payment provider api | ||||
// sent us open -> paid -> open -> paid. So, we lock the payment after it's paid. | // sent us open -> paid -> open -> paid. So, we lock the payment after | ||||
if ($payment->status != self::STATUS_PAID) { | // recivied a "final" state. | ||||
$pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED]; | |||||
if (in_array($payment->status, $pending_states)) { | |||||
$payment->status = $mollie_payment->status; | $payment->status = $mollie_payment->status; | ||||
$payment->save(); | $payment->save(); | ||||
} | } | ||||
if (!empty($credit)) { | |||||
self::creditPayment($payment, $mollie_payment); | |||||
} | |||||
DB::commit(); | |||||
if (!empty($notify)) { | |||||
\App\Jobs\PaymentEmail::dispatch($payment); | |||||
} | |||||
return 200; | return 200; | ||||
} | } | ||||
/** | /** | ||||
* Get Mollie customer identifier for specified wallet. | * Get Mollie customer identifier for specified wallet. | ||||
* Create one if does not exist yet. | * Create one if does not exist yet. | ||||
* | * | ||||
* @param \App\Wallet $wallet The wallet | * @param \App\Wallet $wallet The wallet | ||||
Show All 40 Lines | protected static function mollieMandate(Wallet $wallet) | ||||
foreach ($customer->mandates() as $mandate) { | foreach ($customer->mandates() as $mandate) { | ||||
if ($mandate->isValid() || $mandate->isPending()) { | if ($mandate->isValid() || $mandate->isPending()) { | ||||
$wallet->setSetting('mollie_mandate_id', $mandate->id); | $wallet->setSetting('mollie_mandate_id', $mandate->id); | ||||
return $mandate; | return $mandate; | ||||
} | } | ||||
} | } | ||||
*/ | */ | ||||
} | } | ||||
/** | |||||
* Apply the successful payment's pecunia to the wallet | |||||
*/ | |||||
protected static function creditPayment($payment, $mollie_payment) | |||||
{ | |||||
// Extract the payment method for transaction description | |||||
$method = self::paymentMethod($mollie_payment, 'Mollie'); | |||||
// 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 Mollie payment/mandate details | |||||
*/ | |||||
protected static function paymentMethod($object, $default = ''): string | |||||
{ | |||||
$details = $object->details; | |||||
// Mollie supports 3 methods here | |||||
switch ($object->method) { | |||||
case 'creditcard': | |||||
// If the customer started, but never finished the 'first' payment | |||||
// card details will be empty, and mandate will be 'pending'. | |||||
if (empty($details->cardNumber)) { | |||||
return 'Credit Card'; | |||||
} | |||||
return sprintf( | |||||
'%s (**** **** **** %s)', | |||||
$details->cardLabel ?: 'Card', // @phpstan-ignore-line | |||||
$details->cardNumber | |||||
); | |||||
case 'directdebit': | |||||
return sprintf('Direct Debit (%s)', $details->customerAccount); | |||||
case 'paypal': | |||||
return sprintf('PayPal (%s)', $details->consumerAccount); | |||||
} | |||||
return $default; | |||||
} | |||||
} | } |
Typo.