diff --git a/src/app/Http/Controllers/API/V4/PaymentsController.php b/src/app/Http/Controllers/API/V4/PaymentsController.php --- a/src/app/Http/Controllers/API/V4/PaymentsController.php +++ b/src/app/Http/Controllers/API/V4/PaymentsController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Providers\PaymentProvider; use App\Wallet; +use App\Payment; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; @@ -55,6 +56,7 @@ $mandate = [ 'currency' => 'CHF', 'description' => \config('app.name') . ' Auto-Payment Setup', + 'methodId' => $request->methodId ]; // Normally the auto-payment setup operation is 0, if the balance is below the threshold @@ -217,8 +219,9 @@ $request = [ 'type' => PaymentProvider::TYPE_ONEOFF, - 'currency' => 'CHF', + 'currency' => $request->currency, 'amount' => $amount, + 'methodId' => $request->methodId, 'description' => \config('app.name') . ' Payment', ]; @@ -231,6 +234,31 @@ return response()->json($result); } + /** + * Delete a pending payment. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function cancel(Request $request) + { + $current_user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $current_user->wallets()->first(); + + $paymentId = $request->payment; + + $provider = PaymentProvider::factory($wallet); + + $result = $provider->cancel($wallet, $paymentId); + + $result['status'] = 'success'; + + return response()->json($result); + } + /** * Update payment status (and balance). * @@ -325,4 +353,85 @@ return $mandate; } + + + /** + * List supported payment methods. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public static function paymentMethods(Request $request) + { + $user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $user->wallets()->first(); + + $methods = PaymentProvider::paymentMethods($wallet, $request->type); + + \Log::debug("Provider methods" . var_export(json_encode($methods), true)); + + return response()->json($methods); + } + + + /** + * List pending payments. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public static function payments(Request $request) + { + $user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $user->wallets()->first(); + + $provider = PaymentProvider::factory($wallet); + $pageSize = 10; + $page = intval(request()->input('page')) ?: 1; + $hasMore = false; + $result = Payment::where('wallet_id', $wallet->id) + ->where('type', PaymentProvider::TYPE_ONEOFF) + ->whereIn('status', [ + PaymentProvider::STATUS_OPEN, + PaymentProvider::STATUS_PENDING, + PaymentProvider::STATUS_AUTHORIZED]) + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($item) use ($provider) { + $payment = $provider->getPayment($item->id); + $entry = [ + 'id' => $item->id, + 'createdAt' => $item->created_at->format('Y-m-d H:i'), + 'type' => $item->type, + 'description' => $item->description, + 'amount' => $item->amount, + 'status' => $item->status, + 'isCancelable' => $payment['isCancelable'], + 'checkoutUrl' => $payment['checkoutUrl'] + ]; + + return $entry; + }); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + 'page' => $page, + ]); + } } diff --git a/src/app/Payment.php b/src/app/Payment.php --- a/src/app/Payment.php +++ b/src/app/Payment.php @@ -7,11 +7,13 @@ /** * A payment operation on a wallet. * - * @property int $amount Amount of money in cents + * @property int $amount Amount of money in cents of CHF * @property string $description Payment description * @property string $id Mollie's Payment ID * @property \App\Wallet $wallet The wallet * @property string $wallet_id The ID of the wallet + * @property int $amountInCurrency Amount of money in cents of $currency + * @property string $currency Currency of this payment */ class Payment extends Model { @@ -30,6 +32,8 @@ 'provider', 'status', 'type', + 'currency', + 'amountInCurrency', ]; /** diff --git a/src/app/Providers/Payment/Mollie.php b/src/app/Providers/Payment/Mollie.php --- a/src/app/Providers/Payment/Mollie.php +++ b/src/app/Providers/Payment/Mollie.php @@ -66,7 +66,7 @@ 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'redirectUrl' => Utils::serviceUrl('/wallet'), 'locale' => 'en_US', - // 'method' => 'creditcard', + 'method' => $payment['methodId'] ]; // Create the payment in Mollie @@ -135,7 +135,8 @@ 'id' => $mandate->id, 'isPending' => $mandate->isPending(), 'isValid' => $mandate->isValid(), - 'method' => self::paymentMethod($mandate, 'Unknown method') + 'method' => self::paymentMethod($mandate, 'Unknown method'), + 'methodId' => $mandate->method ]; return $result; @@ -174,20 +175,22 @@ // Register the user in Mollie, if not yet done $customer_id = self::mollieCustomerId($wallet, true); - // Note: Required fields: description, amount/currency, amount/value + $amount = $payment['amount'] * $this->exchangeRate($wallet->currency, $payment['currency']); + $payment['amountInCurrency'] = $amount; + // Note: Required fields: description, amount/currency, amount/value $request = [ 'amount' => [ 'currency' => $payment['currency'], // a number with two decimals is required - 'value' => sprintf('%.2f', $payment['amount'] / 100), + 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => $payment['type'], 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', - // 'method' => 'creditcard', + 'method' => $payment['methodId'], 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments ]; @@ -210,6 +213,32 @@ ]; } + + /** + * Cancel a pending payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $paymentId Payment Id + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + public function cancel(Wallet $wallet, $paymentId): ?array + { + $response = mollie()->payments()->delete($paymentId); + + $payment['id'] = $response->id; + $payment['status'] = $response->status; + + $this->storePayment($payment, $wallet->id); + + return [ + 'id' => $payment['id'] + ]; + } + + /** * Create a new automatic payment operation. * @@ -243,7 +272,7 @@ 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', - // 'method' => 'creditcard', + 'method' => $payment['methodId'], 'mandateId' => $mandate->id ]; @@ -333,7 +362,7 @@ 'description' => $refund->description, 'amount' => round(floatval($refund->amount->value) * 100), 'type' => self::TYPE_REFUND, - // Note: we assume this is the original payment/wallet currency + 'currency' => $refund->amount->currency ]; } } @@ -348,7 +377,7 @@ 'id' => $chargeback->id, 'amount' => round(floatval($chargeback->amount->value) * 100), 'type' => self::TYPE_CHARGEBACK, - // Note: we assume this is the original payment/wallet currency + 'currency' => $chargeback->amount->currency ]; } } @@ -488,7 +517,7 @@ // Mollie supports 3 methods here switch ($object->method) { - case 'creditcard': + case self::METHOD_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)) { @@ -501,13 +530,90 @@ $details->cardNumber ); - case 'directdebit': + case self::METHOD_DIRECTDEBIT: return sprintf('Direct Debit (%s)', $details->customerAccount); - case 'paypal': + case self::METHOD_PAYPAL: return sprintf('PayPal (%s)', $details->consumerAccount); } return $default; } + + /** + * List supported payment methods. + * + * @param string $type type: 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 + { + $availableMethods = []; + + $mapResult = function ($result) use (&$availableMethods) { + foreach ($result as $method) { + $availableMethods[$method->id] = [ + 'id' => $method->id, + 'name' => $method->description, + 'minimumAmount' => round(floatval($method->minimumAmount->value) * 100), // Converted to cents + 'currency' => $method->minimumAmount->currency, + 'exchangeRate' => $this->exchangeRate('CHF', $method->minimumAmount->currency) + ]; + } + }; + + // Fallback to EUR methods + // TODO enable to unlock payment using methods requiring EUR + //$mapResult(mollie()->methods()->allActive( + // [ + // 'sequenceType' => $type, + // 'amount' => [ + // 'value' => '1.00', + // 'currency' => 'EUR' + // ] + // ] + //)); + + // Prefer CHF methods + $mapResult(mollie()->methods()->allActive( + [ + 'sequenceType' => $type, + 'amount' => [ + 'value' => '1.00', + 'currency' => 'CHF' + ] + ] + )); + return $availableMethods; + } + + /** + * 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 none + */ + public function getPayment($paymentId): ?array + { + $payment = mollie()->payments()->get($paymentId); + + return [ + 'id' => $payment->id, + 'status' => $payment->status, + 'isCancelable' => $payment->isCancelable, + 'checkoutUrl' => $payment->getCheckoutUrl() + ]; + } } diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php --- a/src/app/Providers/Payment/Stripe.php +++ b/src/app/Providers/Payment/Stripe.php @@ -470,4 +470,77 @@ return $default; } + + /** + * List supported payment methods. + * + * @param string $type type: 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 + { + //TODO get this from the stripe API? + $availableMethods = []; + switch ($type) { + case self::TYPE_ONEOFF: + $availableMethods = [ + self::METHOD_CREDITCARD => [ + 'id' => self::METHOD_CREDITCARD, + 'name' => "Credit Card", + 'minimumAmount' => self::MIN_AMOUNT, + 'currency' => 'CHF', + 'exchangeRate' => 1.0 + ], + self::METHOD_PAYPAL => [ + 'id' => self::METHOD_PAYPAL, + 'name' => "PayPal", + 'minimumAmount' => self::MIN_AMOUNT, + 'currency' => 'CHF', + 'exchangeRate' => 1.0 + ] + ]; + break; + case self::TYPE_RECURRING: + $availableMethods = [ + self::METHOD_CREDITCARD => [ + 'id' => self::METHOD_CREDITCARD, + 'name' => "Credit Card", + 'minimumAmount' => self::MIN_AMOUNT, // Converted to cents, + 'currency' => 'CHF', + 'exchangeRate' => 1.0 + ] + ]; + break; + } + + return $availableMethods; + } + + /** + * 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 + */ + public function getPayment($paymentId): ?array + { + return [ + 'id' => $paymentId, + 'status' => null, + 'isCancelable' => false, + 'checkoutUrl' => null + ]; + } } diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -5,6 +5,7 @@ use App\Transaction; use App\Payment; use App\Wallet; +use Illuminate\Support\Facades\Cache; abstract class PaymentProvider { @@ -22,35 +23,55 @@ public const TYPE_REFUND = 'refund'; 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) */ public const MIN_AMOUNT = 1000; + /** - * Factory method + * Detect the name of the provider * * @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->getSetting('stripe_id')) { - $provider = 'stripe'; + $provider = self::PROVIDER_STRIPE; } elseif ($provider_or_wallet->getSetting('mollie_id')) { - $provider = 'mollie'; + $provider = self::PROVIDER_MOLLIE; } } else { $provider = $provider_or_wallet; } if (empty($provider)) { - $provider = \config('services.payment_provider') ?: 'mollie'; + $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE; } - switch (\strtolower($provider)) { - case 'stripe': + return \strtolower($provider); + } + + /** + * 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(); - case 'mollie': + case self::PROVIDER_MOLLIE: return new \App\Providers\Payment\Mollie(); default: @@ -157,6 +178,24 @@ 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) { + throw new \Exception("Currency conversion is not yet implemented."); + //FIXME Not yet implemented + } + return 1.0; + } + /** * Deduct an amount of pecunia from the wallet. * Creates a payment and transaction records for the refund/chargeback operation. @@ -172,7 +211,14 @@ return; } - $wallet->balance -= $refund['amount']; + // Preserve originally refunded amount + $refund['amountInCurrency'] = $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; $wallet->save(); if ($refund['type'] == self::TYPE_CHARGEBACK) { @@ -185,13 +231,137 @@ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $transaction_type, - 'amount' => $refund['amount'], + 'amount' => $amount, 'description' => $refund['description'] ?? '', ]); $refund['status'] = self::STATUS_PAID; - $refund['amount'] *= -1; + $refund['amount'] = -1 * $amount; $this->storePayment($refund, $wallet->id); } + + /** + * List supported payment methods from this provider + * + * @param string $type type: 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 + */ + 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' => ['prefix' => 'far', 'name' => 'credit-card'] + ], + self::METHOD_PAYPAL => [ + 'id' => self::METHOD_PAYPAL, + 'icon' => ['prefix' => 'fab', 'name' => 'paypal'] + ], + self::METHOD_BANKTRANSFER => [ + 'id' => self::METHOD_BANKTRANSFER, + 'icon' => ['prefix' => 'fas', 'name' => 'university'] + ] + ]; + case PaymentProvider::TYPE_RECURRING: + return [ + self::METHOD_CREDITCARD => [ + 'id' => self::METHOD_CREDITCARD, + 'icon' => ['prefix' => 'far', 'name' => 'credit-card'] + ], + self::METHOD_PAYPAL => [ + 'id' => self::METHOD_PAYPAL, + 'icon' => ['prefix' => 'fab', 'name' => 'paypal'] + ] + ]; + } + } + + + /** + * 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 type: 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 static function paymentMethods(Wallet $wallet, $type): ?array + { + $providerName = self::providerName($wallet); + + $cacheKey = "methods-" . $providerName . '-' . $type; + + 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)); + + return $methods; + } } diff --git a/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php b/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2021_02_23_084157_payment_table_add_currency_columns.php @@ -0,0 +1,40 @@ +string('currency')->nullable(); + $table->integer('amountInCurrency')->nullable(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'payments', + function (Blueprint $table) { + $table->dropColumn('currency'); + $table->dropColumn('amountInCurrency'); + } + ); + } +} diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -15,6 +15,7 @@ faDownload, faEnvelope, faGlobe, + faUniversity, faExclamationCircle, faInfoCircle, faLock, @@ -30,6 +31,10 @@ faWallet } from '@fortawesome/free-solid-svg-icons' +import { + faPaypal +} from '@fortawesome/free-brands-svg-icons' + // Register only these icons we need library.add( faCheck, @@ -37,6 +42,8 @@ faCheckSquare, faComments, faCreditCard, + faPaypal, + faUniversity, faDownload, faEnvelope, faExclamationCircle, diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -293,6 +293,27 @@ } } +#payment-method-selection { + display: flex; + flex-wrap: wrap; + justify-content: center; + + & > button { + padding: 1rem; + text-align: center; + white-space: nowrap; + margin: 0.25rem; + text-decoration: none; + width: 150px; + } + + svg { + width: 6rem; + height: 6rem; + margin: auto; + } +} + #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -5,8 +5,48 @@
Account balance {{ $root.price(wallet.balance, wallet.currency) }}

{{ wallet.notice }}

-

Add credit to your account or setup an automatic payment by using the button below.

- + +
+ You have payments that are still in progress. See the payments tab below. +
+

+ +

+
+ + +
+
+
+ The configured auto-payment has been disabled. Top up your wallet or + raise the auto-payment amount. +
+ +
+ The setup of the automatic payment is still in progress. +
+

+ +

+

+ +

+
@@ -22,6 +62,11 @@ History +
@@ -53,6 +98,11 @@
+
+
+ +
+