Changeset View
Standalone View
src/app/Providers/PaymentProvider.php
<?php | <?php | ||||
namespace App\Providers; | namespace App\Providers; | ||||
use App\Transaction; | use App\Transaction; | ||||
use App\Payment; | use App\Payment; | ||||
use App\Wallet; | use App\Wallet; | ||||
use Illuminate\Support\Facades\Cache; | |||||
abstract class PaymentProvider | abstract class PaymentProvider | ||||
{ | { | ||||
public const STATUS_OPEN = 'open'; | public const STATUS_OPEN = 'open'; | ||||
public const STATUS_CANCELED = 'canceled'; | public const STATUS_CANCELED = 'canceled'; | ||||
public const STATUS_PENDING = 'pending'; | public const STATUS_PENDING = 'pending'; | ||||
public const STATUS_AUTHORIZED = 'authorized'; | public const STATUS_AUTHORIZED = 'authorized'; | ||||
public const STATUS_EXPIRED = 'expired'; | public const STATUS_EXPIRED = 'expired'; | ||||
public const STATUS_FAILED = 'failed'; | public const STATUS_FAILED = 'failed'; | ||||
public const STATUS_PAID = 'paid'; | public const STATUS_PAID = 'paid'; | ||||
public const TYPE_ONEOFF = 'oneoff'; | public const TYPE_ONEOFF = 'oneoff'; | ||||
public const TYPE_RECURRING = 'recurring'; | public const TYPE_RECURRING = 'recurring'; | ||||
public const TYPE_MANDATE = 'mandate'; | public const TYPE_MANDATE = 'mandate'; | ||||
public const TYPE_REFUND = 'refund'; | public const TYPE_REFUND = 'refund'; | ||||
public const TYPE_CHARGEBACK = 'chargeback'; | public const TYPE_CHARGEBACK = 'chargeback'; | ||||
public const METHOD_CREDITCARD = 'creditcard'; | |||||
public const METHOD_PAYPAL = 'paypal'; | |||||
public const METHOD_BANKTRANSFER = 'banktransfer'; | |||||
public const METHOD_DIRECTDEBIT = 'directdebit'; | |||||
public const PROVIDER_MOLLIE = 'mollie'; | |||||
public const PROVIDER_STRIPE = 'stripe'; | |||||
/** const int Minimum amount of money in a single payment (in cents) */ | /** const int Minimum amount of money in a single payment (in cents) */ | ||||
public const MIN_AMOUNT = 1000; | public const MIN_AMOUNT = 1000; | ||||
private static $paymentMethodIcons = [ | |||||
self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'], | |||||
self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'], | |||||
self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university'] | |||||
]; | |||||
/** | /** | ||||
* Factory method | * Detect the name of the provider | ||||
* | * | ||||
* @param \App\Wallet|string|null $provider_or_wallet | * @param \App\Wallet|string|null $provider_or_wallet | ||||
* @return string The name of the provider | |||||
*/ | */ | ||||
public static function factory($provider_or_wallet = null) | private static function providerName($provider_or_wallet = null): string | ||||
{ | { | ||||
if ($provider_or_wallet instanceof Wallet) { | if ($provider_or_wallet instanceof Wallet) { | ||||
if ($provider_or_wallet->getSetting('stripe_id')) { | if ($provider_or_wallet->getSetting('stripe_id')) { | ||||
$provider = 'stripe'; | $provider = self::PROVIDER_STRIPE; | ||||
} elseif ($provider_or_wallet->getSetting('mollie_id')) { | } elseif ($provider_or_wallet->getSetting('mollie_id')) { | ||||
$provider = 'mollie'; | $provider = self::PROVIDER_MOLLIE; | ||||
} | } | ||||
} else { | } else { | ||||
$provider = $provider_or_wallet; | $provider = $provider_or_wallet; | ||||
} | } | ||||
if (empty($provider)) { | if (empty($provider)) { | ||||
$provider = \config('services.payment_provider') ?: 'mollie'; | $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE; | ||||
} | } | ||||
switch (\strtolower($provider)) { | return \strtolower($provider); | ||||
case 'stripe': | } | ||||
/** | |||||
* Factory method | |||||
* | |||||
* @param \App\Wallet|string|null $provider_or_wallet | |||||
*/ | |||||
public static function factory($provider_or_wallet = null) | |||||
{ | |||||
switch (self::providerName($provider_or_wallet)) { | |||||
case self::PROVIDER_STRIPE: | |||||
return new \App\Providers\Payment\Stripe(); | return new \App\Providers\Payment\Stripe(); | ||||
case 'mollie': | case self::PROVIDER_MOLLIE: | ||||
return new \App\Providers\Payment\Mollie(); | return new \App\Providers\Payment\Mollie(); | ||||
default: | default: | ||||
throw new \Exception("Invalid payment provider: {$provider}"); | throw new \Exception("Invalid payment provider: {$provider}"); | ||||
} | } | ||||
} | } | ||||
/** | /** | ||||
▲ Show 20 Lines • Show All 91 Lines • ▼ Show 20 Lines | protected function storePayment(array $payment, $wallet_id): Payment | ||||
$db_payment->wallet_id = $wallet_id; | $db_payment->wallet_id = $wallet_id; | ||||
$db_payment->provider = $this->name(); | $db_payment->provider = $this->name(); | ||||
$db_payment->save(); | $db_payment->save(); | ||||
return $db_payment; | return $db_payment; | ||||
} | } | ||||
/** | /** | ||||
* Retrieve an exchange rate. | |||||
* | |||||
* @param \App\Wallet $wallet The wallet | |||||
* @param string $sourceCurrency: Currency from which to convert | |||||
* @param string $targetCurrency: Currency to convert to | |||||
* | |||||
* @return float Exchange rate | |||||
*/ | |||||
protected function exchangeRate(string $sourceCurrency, string $targetCurrency): float | |||||
{ | |||||
if ($sourceCurrency != $targetCurrency) { | |||||
machniak: This probably should be a case-insensitive check. | |||||
throw new \Exception("Currency conversion is not yet implemented."); | |||||
//FIXME Not yet implemented | |||||
Done Inline ActionsI would throw an exceptions here. machniak: I would throw an exceptions here. | |||||
} | |||||
return 1.0; | |||||
} | |||||
/** | |||||
* Deduct an amount of pecunia from the wallet. | * Deduct an amount of pecunia from the wallet. | ||||
* Creates a payment and transaction records for the refund/chargeback operation. | * Creates a payment and transaction records for the refund/chargeback operation. | ||||
Done Inline ActionsI would use one space before $, and no double-colon after the argument name. Descriptions should be aligned with each other. machniak: I would use one space before $, and no double-colon after the argument name. Descriptions… | |||||
* | * | ||||
* @param \App\Wallet $wallet A wallet object | * @param \App\Wallet $wallet A wallet object | ||||
Done Inline ActionsI would prefer the amount to be the 1st argument, but of course it's not a strong requirement. machniak: I would prefer the amount to be the 1st argument, but of course it's not a strong requirement. | |||||
* @param array $refund A refund or chargeback data (id, type, amount, description) | * @param array $refund A refund or chargeback data (id, type, amount, description) | ||||
* | * | ||||
* @return void | * @return void | ||||
*/ | */ | ||||
protected function storeRefund(Wallet $wallet, array $refund): void | protected function storeRefund(Wallet $wallet, array $refund): void | ||||
{ | { | ||||
if (empty($refund) || empty($refund['amount'])) { | if (empty($refund) || empty($refund['amount'])) { | ||||
return; | return; | ||||
} | } | ||||
$wallet->balance -= $refund['amount']; | // Preserve originally refunded amount | ||||
$refund['currency_amount'] = $refund['amount']; | |||||
// Convert amount to wallet currency | |||||
// TODO We should possibly be using the same exchange rate as for the original payment? | |||||
$amount = round($refund['amount'] * $this->exchangeRate($refund['currency'], $wallet->currency)); | |||||
$wallet->balance -= $amount; | |||||
Done Inline Actions$amount would have to be rounded. machniak: $amount would have to be rounded. | |||||
$wallet->save(); | $wallet->save(); | ||||
if ($refund['type'] == self::TYPE_CHARGEBACK) { | if ($refund['type'] == self::TYPE_CHARGEBACK) { | ||||
$transaction_type = Transaction::WALLET_CHARGEBACK; | $transaction_type = Transaction::WALLET_CHARGEBACK; | ||||
} else { | } else { | ||||
$transaction_type = Transaction::WALLET_REFUND; | $transaction_type = Transaction::WALLET_REFUND; | ||||
} | } | ||||
Transaction::create([ | Transaction::create([ | ||||
'object_id' => $wallet->id, | 'object_id' => $wallet->id, | ||||
'object_type' => Wallet::class, | 'object_type' => Wallet::class, | ||||
'type' => $transaction_type, | 'type' => $transaction_type, | ||||
'amount' => $refund['amount'], | 'amount' => $amount, | ||||
'description' => $refund['description'] ?? '', | 'description' => $refund['description'] ?? '', | ||||
]); | ]); | ||||
$refund['status'] = self::STATUS_PAID; | $refund['status'] = self::STATUS_PAID; | ||||
$refund['amount'] *= -1; | $refund['amount'] = -1 * $amount; | ||||
$this->storePayment($refund, $wallet->id); | $this->storePayment($refund, $wallet->id); | ||||
} | } | ||||
/** | |||||
* List supported payment methods from this provider | |||||
* | |||||
* @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 | |||||
*/ | |||||
Done Inline ActionsI think there should be static paymentMethods() method in the PaymentProvider. I.e. I'm thinking about a case when we'd like to enable methods from more that one payment provider. I.e. if we add bitcoin provider we would use Mollie and this other provider. We need a common method that returns all enabled (whitelisted) methods from all providers. This is also another reason to cache the list, and even without it, I think it would make sense to omit a request to the provider's server whenever possible. machniak: I think there should be static paymentMethods() method in the PaymentProvider. I.e. I'm… | |||||
abstract public function providerPaymentMethods($type): ?array; | |||||
/** | |||||
* Get a payment. | |||||
* | |||||
* @param string $paymentId Payment identifier | |||||
* | |||||
* @return array|null Payment information: | |||||
* - id: Payment identifier | |||||
* - status: Payment status | |||||
* - isCancelable: The payment can be canceled | |||||
* - chceckoutUrl: The checkout url to complete the payment or null if non | |||||
*/ | |||||
abstract public function getPayment($paymentId): ?array; | |||||
/** | |||||
* Return an array of whitelisted payment methods with override values. | |||||
* | |||||
* @param string $type The payment type for which we require a method. | |||||
* | |||||
* @return array Array of methods | |||||
*/ | |||||
protected static function paymentMethodsWhitelist($type): ?array | |||||
{ | |||||
switch ($type) { | |||||
case self::TYPE_ONEOFF: | |||||
return [ | |||||
self::METHOD_CREDITCARD => [ | |||||
'id' => self::METHOD_CREDITCARD, | |||||
'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD] | |||||
], | |||||
self::METHOD_PAYPAL => [ | |||||
'id' => self::METHOD_PAYPAL, | |||||
'icon' => self::$paymentMethodIcons[self::METHOD_PAYPAL] | |||||
], | |||||
self::METHOD_BANKTRANSFER => [ | |||||
Done Inline ActionsShould we comment out the bank transfer for now? E.g. we could enable the method in Mollie while working on bank transfers without revealing it to users on production. Or do I miss something? machniak: Should we comment out the bank transfer for now? E.g. we could enable the method in Mollie… | |||||
'id' => self::METHOD_BANKTRANSFER, | |||||
'icon' => self::$paymentMethodIcons[self::METHOD_BANKTRANSFER] | |||||
] | |||||
]; | |||||
case PaymentProvider::TYPE_RECURRING: | |||||
return [ | |||||
self::METHOD_CREDITCARD => [ | |||||
'id' => self::METHOD_CREDITCARD, | |||||
'icon' => self::$paymentMethodIcons[self::METHOD_CREDITCARD] | |||||
], | |||||
self::METHOD_PAYPAL => [ | |||||
'id' => self::METHOD_PAYPAL, | |||||
'icon' => self::$paymentMethodIcons[self::METHOD_PAYPAL] | |||||
Done Inline ActionsCode duplication. I would probably create a class property with type-to-icon map. machniak: Code duplication. I would probably create a class property with type-to-icon map. | |||||
Done Inline ActionsThere's still some duplication, but attempting to deduplicate this any further will make it overall less maintainable. mollekopf: There's still some duplication, but attempting to deduplicate this any further will make it… | |||||
] | |||||
]; | |||||
} | |||||
} | |||||
/** | |||||
Done Inline ActionsSingle empty line between methods, please. machniak: Single empty line between methods, please. | |||||
* Return an array of whitelisted payment methods with override values. | |||||
* | |||||
* @param string $type The payment type for which we require a method. | |||||
* | |||||
* @return array Array of methods | |||||
*/ | |||||
private static function applyMethodWhitelist($type, $availableMethods): ?array | |||||
{ | |||||
$methods = []; | |||||
// Use only whitelisted methods, and apply values from whitelist (overriding the backend) | |||||
$whitelistMethods = self::paymentMethodsWhitelist($type); | |||||
foreach ($whitelistMethods as $id => $whitelistMethod) { | |||||
if (array_key_exists($id, $availableMethods)) { | |||||
$methods[] = array_merge($availableMethods[$id], $whitelistMethod); | |||||
} | |||||
} | |||||
return $methods; | |||||
} | |||||
/** | |||||
* List supported payment methods for $wallet | |||||
* | |||||
* @param \App\Wallet $wallet The wallet | |||||
* @param string $type The payment type for which we require a method (oneoff/recurring). | |||||
* | |||||
* @return array Array of array with available payment methods: | |||||
Done Inline ActionsProper spacing, please. machniak: Proper spacing, please. | |||||
* - 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 static function paymentMethods(Wallet $wallet, $type): ?array | |||||
{ | |||||
$providerName = self::providerName($wallet); | |||||
$cacheKey = "methods-" . $providerName . '-' . $type; | |||||
machniakUnsubmitted Not Done Inline ActionsYou ignored my comment so again, this time inline: Because it's likely that user will open both Add credit and Setup auto-payment dialogs. It would make sense to fetch the list of payment methods only once. I.e. it could return all methods with some type flag so we could filter it client-side. This also would make less requests to Mollie. I think it would be worth implementing. machniak: You ignored my comment so again, this time inline:
Because it's likely that user will open… | |||||
mollekopfAuthorUnsubmitted Done Inline ActionsI think it's more complex and all that we're saving is potentially a single request from the client to the server. For mollie I don't think we can request all types in one go (omitting the type defaults to oneoff), and we're caching the result anyways. So overall I'm not convinced the alternative of requesting all methods and filtering clientside is better at all, and the current implementation already exists. So unless you feel strongly about this I would rather not change it. mollekopf: I think it's more complex and all that we're saving is potentially a single request from the… | |||||
machniakUnsubmitted Not Done Inline ActionsI'll not die for it, but it looks like Mollie has an API to ask for all methods, https://docs.mollie.com/reference/v2/methods-api/list-all-methods. machniak: I'll not die for it, but it looks like Mollie has an API to ask for all methods, https://docs. | |||||
mollekopfAuthorUnsubmitted Done Inline ActionsLet's defer it to a later iteration then please. I've created a ticket for it: https://bifrost.kolabsystems.com/T423548 mollekopf: Let's defer it to a later iteration then please. I've created a ticket for it: https://bifrost. | |||||
if ($methods = Cache::get($cacheKey)) { | |||||
\Log::debug("Using payment method cache" . var_export($methods, true)); | |||||
return $methods; | |||||
} | |||||
$provider = PaymentProvider::factory($providerName); | |||||
$methods = self::applyMethodWhitelist($type, $provider->providerPaymentMethods($type)); | |||||
Cache::put($cacheKey, $methods, now()->addHours(1)); | |||||
Not Done Inline ActionsI guess we could cache for longer, but maybe not include the exchange-rate in the cache. machniak: I guess we could cache for longer, but maybe not include the exchange-rate in the cache. | |||||
Not Done Inline ActionsI guess we could, but then I also don't know what is reasonable. A couple reuqests per hour seem unproblematic to me. mollekopf: I guess we could, but then I also don't know what is reasonable. A couple reuqests per hour… | |||||
return $methods; | |||||
} | |||||
} | } |
This probably should be a case-insensitive check.