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_or_wallet}"); | ||||
} | } | ||||
} | } | ||||
/** | /** | ||||
* 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 | * - amount: Value in cents | ||||
* - currency: The operation currency | * - currency: The operation currency | ||||
* - 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 | ||||
*/ | */ | ||||
abstract public function createMandate(Wallet $wallet, array $payment): ?array; | abstract public function createMandate(Wallet $wallet, array $payment): ?array; | ||||
/** | /** | ||||
* Revoke the auto-payment mandate for a wallet. | * Revoke the auto-payment mandate for a wallet. | ||||
* | * | ||||
* @param \App\Wallet $wallet The wallet | * @param \App\Wallet $wallet The wallet | ||||
* | * | ||||
* @return bool True on success, False on failure | * @return bool True on success, False on failure | ||||
*/ | */ | ||||
abstract public function deleteMandate(Wallet $wallet): bool; | abstract public function deleteMandate(Wallet $wallet): bool; | ||||
/** | /** | ||||
* Get a auto-payment mandate for a wallet. | * Get a auto-payment mandate for a 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 | ||||
* - isValid: the mandate is valid | * - isValid: the mandate is valid | ||||
*/ | */ | ||||
abstract public function getMandate(Wallet $wallet): ?array; | abstract public function getMandate(Wallet $wallet): ?array; | ||||
/** | /** | ||||
* Get a link to the customer in the provider's control panel | * Get a link to the customer in the provider's control panel | ||||
* | * | ||||
Show All 14 Lines | abstract class PaymentProvider | ||||
* 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: first/oneoff/recurring | * - type: first/oneoff/recurring | ||||
* - description: Operation description | * - description: Operation description | ||||
* - methodId: Payment method | |||||
* | * | ||||
* @return array Provider payment/session data: | * @return array Provider payment/session data: | ||||
* - id: Operation identifier | * - id: Operation identifier | ||||
* - redirectUrl | * - redirectUrl | ||||
*/ | */ | ||||
abstract public function payment(Wallet $wallet, array $payment): ?array; | abstract public function payment(Wallet $wallet, array $payment): ?array; | ||||
/** | /** | ||||
Show All 16 Lines | protected function storePayment(array $payment, $wallet_id): Payment | ||||
$db_payment = new Payment(); | $db_payment = new Payment(); | ||||
$db_payment->id = $payment['id']; | $db_payment->id = $payment['id']; | ||||
$db_payment->description = $payment['description'] ?? ''; | $db_payment->description = $payment['description'] ?? ''; | ||||
$db_payment->status = $payment['status'] ?? self::STATUS_OPEN; | $db_payment->status = $payment['status'] ?? self::STATUS_OPEN; | ||||
$db_payment->amount = $payment['amount'] ?? 0; | $db_payment->amount = $payment['amount'] ?? 0; | ||||
$db_payment->type = $payment['type']; | $db_payment->type = $payment['type']; | ||||
$db_payment->wallet_id = $wallet_id; | $db_payment->wallet_id = $wallet_id; | ||||
$db_payment->provider = $this->name(); | $db_payment->provider = $this->name(); | ||||
$db_payment->currency = $payment['currency']; | |||||
$db_payment->currency_amount = $payment['currency_amount']; | |||||
$db_payment->save(); | $db_payment->save(); | ||||
return $db_payment; | return $db_payment; | ||||
} | } | ||||
/** | /** | ||||
* Retrieve an exchange rate. | |||||
* | |||||
* @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 (strcasecmp($sourceCurrency, $targetCurrency)) { | |||||
throw new \Exception("Currency conversion is not yet implemented."); | |||||
machniak: This probably should be a case-insensitive check. | |||||
//FIXME Not yet implemented | |||||
} | |||||
Done Inline ActionsI would throw an exceptions here. machniak: I would throw an exceptions here. | |||||
return 1.0; | |||||
} | |||||
/** | |||||
* Convert a value from $sourceCurrency to $targetCurrency | |||||
* | |||||
* @param int $amount Amount in cents of $sourceCurrency | |||||
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 string $sourceCurrency Currency from which to convert | |||||
* @param string $targetCurrency Currency to convert to | |||||
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. | |||||
* | |||||
* @return int Exchanged amount in cents of $targetCurrency | |||||
*/ | |||||
protected function exchange(int $amount, string $sourceCurrency, string $targetCurrency): int | |||||
{ | |||||
return intval(round($amount * $this->exchangeRate($sourceCurrency, $targetCurrency))); | |||||
} | |||||
/** | |||||
* 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. | ||||
* | * | ||||
* @param \App\Wallet $wallet A wallet object | * @param \App\Wallet $wallet A wallet object | ||||
* @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 = $this->exchange($refund['amount'], $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'] * -1, | 'amount' => $amount * -1, | ||||
'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 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 | |||||
*/ | |||||
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] | |||||
], | |||||
// TODO Enable once we're ready to offer them | |||||
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… | |||||
// self::METHOD_BANKTRANSFER => [ | |||||
// '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] | |||||
] | |||||
]; | |||||
} | |||||
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… | |||||
\Log::error("Unknown payment type: " . $type); | |||||
return []; | |||||
} | |||||
/** | |||||
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; | |||||
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… | |||||
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… | |||||
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. | |||||
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.