Changeset View
Standalone View
src/app/Providers/Payment/Mollie.php
Show All 34 Lines | class Mollie extends \App\Providers\PaymentProvider | ||||
/** | /** | ||||
* Create a new auto-payment mandate for a wallet. | * Create a new auto-payment mandate for a wallet. | ||||
* | * | ||||
* @param \App\Wallet $wallet The wallet | * @param \App\Wallet $wallet The wallet | ||||
* @param array $payment Payment data: | * @param array $payment Payment data: | ||||
* - amount: Value in cents (optional) | * - amount: Value in cents (optional) | ||||
* - currency: The operation currency | * - currency: The operation currency | ||||
* - description: Operation desc. | * - description: Operation desc. | ||||
* - methodId: Payment method | |||||
machniak: Mention `methodId` argument here. | |||||
* | * | ||||
* @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 createMandate(Wallet $wallet, array $payment): ?array | public function createMandate(Wallet $wallet, array $payment): ?array | ||||
{ | { | ||||
// Register the user in Mollie, if not yet done | // Register the user in Mollie, if not yet done | ||||
$customer_id = self::mollieCustomerId($wallet, true); | $customer_id = self::mollieCustomerId($wallet, true); | ||||
if (!isset($payment['amount'])) { | if (!isset($payment['amount'])) { | ||||
$payment['amount'] = 0; | $payment['amount'] = 0; | ||||
} | } | ||||
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); | |||||
$payment['currency_amount'] = $amount; | |||||
$request = [ | $request = [ | ||||
'amount' => [ | 'amount' => [ | ||||
'currency' => $payment['currency'], | 'currency' => $payment['currency'], | ||||
'value' => sprintf('%.2f', $payment['amount'] / 100), | 'value' => sprintf('%.2f', $amount / 100), | ||||
], | ], | ||||
'customerId' => $customer_id, | 'customerId' => $customer_id, | ||||
'sequenceType' => 'first', | 'sequenceType' => 'first', | ||||
'description' => $payment['description'], | 'description' => $payment['description'], | ||||
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), | 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), | ||||
'redirectUrl' => Utils::serviceUrl('/wallet'), | 'redirectUrl' => Utils::serviceUrl('/wallet'), | ||||
'locale' => 'en_US', | 'locale' => 'en_US', | ||||
// 'method' => 'creditcard', | 'method' => $payment['methodId'] | ||||
]; | ]; | ||||
// Create the payment in Mollie | // Create the payment in Mollie | ||||
$response = mollie()->payments()->create($request); | $response = mollie()->payments()->create($request); | ||||
if ($response->mandateId) { | if ($response->mandateId) { | ||||
$wallet->setSetting('mollie_mandate_id', $response->mandateId); | $wallet->setSetting('mollie_mandate_id', $response->mandateId); | ||||
} | } | ||||
Show All 36 Lines | class Mollie extends \App\Providers\PaymentProvider | ||||
/** | /** | ||||
* Get a auto-payment mandate for the wallet. | * Get a auto-payment mandate for the wallet. | ||||
* | * | ||||
* @param \App\Wallet $wallet The wallet | * @param \App\Wallet $wallet The wallet | ||||
* | * | ||||
* @return array|null Mandate information: | * @return array|null Mandate information: | ||||
* - id: Mandate identifier | * - id: Mandate identifier | ||||
* - method: user-friendly payment method desc. | * - method: user-friendly payment method desc. | ||||
* - methodId: Payment method | |||||
* - isPending: the process didn't complete yet | * - isPending: the process didn't complete yet | ||||
Done Inline ActionsMention methodId here. machniak: Mention `methodId` here. | |||||
* - isValid: the mandate is valid | * - isValid: the mandate is valid | ||||
*/ | */ | ||||
public function getMandate(Wallet $wallet): ?array | public function getMandate(Wallet $wallet): ?array | ||||
{ | { | ||||
// Get the Mandate info | // Get the Mandate info | ||||
$mandate = self::mollieMandate($wallet); | $mandate = self::mollieMandate($wallet); | ||||
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') | 'method' => self::paymentMethod($mandate, 'Unknown method'), | ||||
'methodId' => $mandate->method | |||||
]; | ]; | ||||
return $result; | return $result; | ||||
} | } | ||||
/** | /** | ||||
* Get a provider name | * Get a provider name | ||||
* | * | ||||
* @return string Provider name | * @return string Provider name | ||||
*/ | */ | ||||
public function name(): string | public function name(): string | ||||
{ | { | ||||
return 'mollie'; | return 'mollie'; | ||||
} | } | ||||
/** | /** | ||||
* Create a new payment. | * Create a new payment. | ||||
* | * | ||||
* @param \App\Wallet $wallet The wallet | * @param \App\Wallet $wallet The wallet | ||||
* @param array $payment Payment data: | * @param array $payment Payment data: | ||||
* - amount: Value in cents | * - amount: Value in cents | ||||
* - currency: The operation currency | * - currency: The operation currency | ||||
* - type: oneoff/recurring | * - type: oneoff/recurring | ||||
* - description: Operation desc. | * - description: Operation desc. | ||||
* - methodId: Payment method | |||||
* | * | ||||
* @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) { | if ($payment['type'] == self::TYPE_RECURRING) { | ||||
return $this->paymentRecurring($wallet, $payment); | 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, true); | $customer_id = self::mollieCustomerId($wallet, true); | ||||
// Note: Required fields: description, amount/currency, amount/value | $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); | ||||
$payment['currency_amount'] = $amount; | |||||
// 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 (note that JPK and ISK don't require decimals, | ||||
'value' => sprintf('%.2f', $payment['amount'] / 100), | // but we're not using them currently) | ||||
'value' => sprintf('%.2f', $amount / 100), | |||||
Done Inline ActionsJust a note that there are two currencies (JPK, ISK) that require no decimal places. All other work with 2 so we're fine. machniak: Just a note that there are two currencies (JPK, ISK) that require no decimal places. All other… | |||||
], | ], | ||||
'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' => $payment['methodId'], | ||||
'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments | 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments | ||||
]; | ]; | ||||
// TODO: Additional payment parameters for better fraud protection: | // TODO: Additional payment parameters for better fraud protection: | ||||
// billingEmail - for bank transfers, Przelewy24, but not creditcard | // billingEmail - for bank transfers, Przelewy24, but not creditcard | ||||
// billingAddress (it is a structured field not just text) | // billingAddress (it is a structured field not just text) | ||||
// 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; | ||||
$this->storePayment($payment, $wallet->id); | $this->storePayment($payment, $wallet->id); | ||||
return [ | return [ | ||||
'id' => $payment['id'], | 'id' => $payment['id'], | ||||
'redirectUrl' => $response->getCheckoutUrl(), | 'redirectUrl' => $response->getCheckoutUrl(), | ||||
]; | ]; | ||||
} | } | ||||
/** | |||||
* Cancel a pending payment. | |||||
* | |||||
* @param \App\Wallet $wallet The wallet | |||||
* @param string $paymentId Payment Id | |||||
* | |||||
* @return bool True on success, False on failure | |||||
*/ | |||||
public function cancel(Wallet $wallet, $paymentId): bool | |||||
{ | |||||
$response = mollie()->payments()->delete($paymentId); | |||||
$db_payment = Payment::find($paymentId); | |||||
Done Inline ActionsRedundant new-line ;) machniak: Redundant new-line ;) | |||||
$db_payment->status = $response->status; | |||||
Done Inline ActionsThese two lines could be replaced with $db_payment = Payment::find($paymentId); machniak: These two lines could be replaced with `$db_payment = Payment::find($paymentId);`
| |||||
$db_payment->save(); | |||||
return true; | |||||
} | |||||
Done Inline ActionsI would probably not use storePayment() just to update the payment status. And I think this method should return just bool. machniak: I would probably not use storePayment() just to update the payment status. And I think this… | |||||
/** | /** | ||||
* Create a new automatic payment operation. | * Create a new automatic payment operation. | ||||
* | * | ||||
Done Inline ActionscheckoutUrl does not make sense here. machniak: checkoutUrl does not make sense here. | |||||
Done Inline ActionsI'm just using the wallet page now. mollekopf: I'm just using the wallet page now.
I don't think I've been using it at all before. | |||||
Done Inline ActionsMy point is that 'redirectUrl' does not make sense for cancelled payments. So, the API should not contain it here. BTW, yu added 3 new routes. I'd expect some new controller tests. machniak: My point is that 'redirectUrl' does not make sense for cancelled payments. So, the API should… | |||||
* @param \App\Wallet $wallet The wallet | * @param \App\Wallet $wallet The wallet | ||||
* @param array $payment Payment data (see self::payment()) | * @param array $payment Payment data (see self::payment()) | ||||
* | * | ||||
* @return array Provider payment/session data: | * @return array Provider payment/session data: | ||||
* - id: Operation identifier | * - id: Operation identifier | ||||
*/ | */ | ||||
protected function paymentRecurring(Wallet $wallet, array $payment): ?array | 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; | ||||
} | } | ||||
$customer_id = self::mollieCustomerId($wallet, true); | $customer_id = self::mollieCustomerId($wallet, true); | ||||
// Note: Required fields: description, amount/currency, amount/value | // Note: Required fields: description, amount/currency, amount/value | ||||
$amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); | |||||
$payment['currency_amount'] = $amount; | |||||
$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', $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' => $payment['methodId'], | ||||
'mandateId' => $mandate->id | '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; | ||||
▲ Show 20 Lines • Show All 73 Lines • ▼ Show 20 Lines | public function webhook(): int | ||||
if ($mollie_payment->hasRefunds()) { | if ($mollie_payment->hasRefunds()) { | ||||
foreach ($mollie_payment->refunds() as $refund) { | foreach ($mollie_payment->refunds() as $refund) { | ||||
if ($refund->isTransferred() && $refund->amount->value) { | if ($refund->isTransferred() && $refund->amount->value) { | ||||
$refunds[] = [ | $refunds[] = [ | ||||
'id' => $refund->id, | 'id' => $refund->id, | ||||
'description' => $refund->description, | 'description' => $refund->description, | ||||
'amount' => round(floatval($refund->amount->value) * 100), | 'amount' => round(floatval($refund->amount->value) * 100), | ||||
'type' => self::TYPE_REFUND, | 'type' => self::TYPE_REFUND, | ||||
// Note: we assume this is the original payment/wallet currency | 'currency' => $refund->amount->currency | ||||
]; | ]; | ||||
} | } | ||||
} | } | ||||
} | } | ||||
// The payment has been (partially) charged back. | // The payment has been (partially) charged back. | ||||
// Let's process chargebacks (they have no states as refunds) | // Let's process chargebacks (they have no states as refunds) | ||||
if ($mollie_payment->hasChargebacks()) { | if ($mollie_payment->hasChargebacks()) { | ||||
foreach ($mollie_payment->chargebacks() as $chargeback) { | foreach ($mollie_payment->chargebacks() as $chargeback) { | ||||
if ($chargeback->amount->value) { | if ($chargeback->amount->value) { | ||||
$refunds[] = [ | $refunds[] = [ | ||||
'id' => $chargeback->id, | 'id' => $chargeback->id, | ||||
'amount' => round(floatval($chargeback->amount->value) * 100), | 'amount' => round(floatval($chargeback->amount->value) * 100), | ||||
'type' => self::TYPE_CHARGEBACK, | 'type' => self::TYPE_CHARGEBACK, | ||||
// Note: we assume this is the original payment/wallet currency | 'currency' => $chargeback->amount->currency | ||||
]; | ]; | ||||
} | } | ||||
} | } | ||||
} | } | ||||
// In case there were multiple auto-payment setup requests (e.g. caused by a double | // In case there were multiple auto-payment setup requests (e.g. caused by a double | ||||
// form submission) we end up with multiple payment records and mollie_mandate_id | // form submission) we end up with multiple payment records and mollie_mandate_id | ||||
// pointing to the one from the last payment not the successful one. | // pointing to the one from the last payment not the successful one. | ||||
▲ Show 20 Lines • Show All 123 Lines • ▼ Show 20 Lines | class Mollie extends \App\Providers\PaymentProvider | ||||
* Extract payment method description from Mollie payment/mandate details | * Extract payment method description from Mollie payment/mandate details | ||||
*/ | */ | ||||
protected static function paymentMethod($object, $default = ''): string | protected static function paymentMethod($object, $default = ''): string | ||||
{ | { | ||||
$details = $object->details; | $details = $object->details; | ||||
// Mollie supports 3 methods here | // Mollie supports 3 methods here | ||||
switch ($object->method) { | switch ($object->method) { | ||||
case 'creditcard': | case self::METHOD_CREDITCARD: | ||||
// If the customer started, but never finished the 'first' payment | // If the customer started, but never finished the 'first' payment | ||||
// card details will be empty, and mandate will be 'pending'. | // card details will be empty, and mandate will be 'pending'. | ||||
if (empty($details->cardNumber)) { | if (empty($details->cardNumber)) { | ||||
return 'Credit Card'; | return 'Credit Card'; | ||||
} | } | ||||
return sprintf( | return sprintf( | ||||
'%s (**** **** **** %s)', | '%s (**** **** **** %s)', | ||||
$details->cardLabel ?: 'Card', // @phpstan-ignore-line | $details->cardLabel ?: 'Card', // @phpstan-ignore-line | ||||
$details->cardNumber | $details->cardNumber | ||||
); | ); | ||||
case 'directdebit': | case self::METHOD_DIRECTDEBIT: | ||||
return sprintf('Direct Debit (%s)', $details->customerAccount); | return sprintf('Direct Debit (%s)', $details->customerAccount); | ||||
case 'paypal': | case self::METHOD_PAYPAL: | ||||
return sprintf('PayPal (%s)', $details->consumerAccount); | return sprintf('PayPal (%s)', $details->consumerAccount); | ||||
} | } | ||||
return $default; | return $default; | ||||
} | } | ||||
/** | |||||
* List supported payment methods. | |||||
* | |||||
* @param string $type The payment type for which we require a method (oneoff/recurring). | |||||
* | |||||
* @return array Array of array with available payment methods: | |||||
* - id: id of the method | |||||
* - name: User readable name of the payment method | |||||
* - minimumAmount: Minimum amount to be charged in cents | |||||
* - currency: Currency used for the method | |||||
* - exchangeRate: The projected exchange rate (actual rate is determined during payment) | |||||
* - icon: An icon (icon name) representing the method | |||||
*/ | |||||
public function providerPaymentMethods($type): array | |||||
{ | |||||
$providerMethods = array_merge( | |||||
// Fallback to EUR methods (later provider methods will override earlier ones) | |||||
//mollie()->methods()->allActive( | |||||
// [ | |||||
// 'sequenceType' => $type, | |||||
// 'amount' => [ | |||||
// 'value' => '1.00', | |||||
// 'currency' => 'EUR' | |||||
// ] | |||||
// ] | |||||
//), | |||||
// Prefer CHF methods | |||||
(array)mollie()->methods()->allActive( | |||||
[ | |||||
'sequenceType' => $type, | |||||
'amount' => [ | |||||
'value' => '1.00', | |||||
'currency' => 'CHF' | |||||
] | |||||
] | |||||
) | |||||
); | |||||
$availableMethods = []; | |||||
foreach ($providerMethods as $method) { | |||||
$availableMethods[$method->id] = [ | |||||
'id' => $method->id, | |||||
Done Inline ActionsUse of lambda function does not make much sense here. You can just do foreach on the mollie()->methods()->allActive() result. machniak: Use of lambda function does not make much sense here. You can just do foreach on the mollie()… | |||||
'name' => $method->description, | |||||
'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents | |||||
'currency' => $method->minimumAmount->currency, | |||||
'exchangeRate' => $this->exchangeRate('CHF', $method->minimumAmount->currency) | |||||
]; | |||||
} | |||||
return $availableMethods; | |||||
Done Inline ActionsI think it would make sense to cache the list of available methods, so we don't fetch it all the time, but maybe once a day or something like that. machniak: I think it would make sense to cache the list of available methods, so we don't fetch it all… | |||||
Done Inline ActionsIn principle I suppose that is enough, but then we would have to start handling changes (if we assume it's static we could just hardcode it). Do you think this will in practice become a problem and needs addressing? mollekopf: In principle I suppose that is enough, but then we would have to start handling changes (if we… | |||||
} | |||||
/** | |||||
* Get a payment. | |||||
* | |||||
* @param string $paymentId Payment identifier | |||||
* | |||||
* @return array Payment information: | |||||
* - id: Payment identifier | |||||
* - status: Payment status | |||||
* - isCancelable: The payment can be canceled | |||||
* - checkoutUrl: The checkout url to complete the payment or null if none | |||||
*/ | |||||
Done Inline Actionsnon -> none machniak: non -> none | |||||
public function getPayment($paymentId): array | |||||
Done Inline ActionsLooks to me like it always returns an array and never null. machniak: Looks to me like it always returns an array and never null. | |||||
{ | |||||
$payment = mollie()->payments()->get($paymentId); | |||||
return [ | |||||
'id' => $payment->id, | |||||
'status' => $payment->status, | |||||
'isCancelable' => $payment->isCancelable, | |||||
'checkoutUrl' => $payment->getCheckoutUrl() | |||||
]; | |||||
} | |||||
} | } |
Mention methodId argument here.