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,32 @@ 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); + + //TODO handle errors? + $result['status'] = 'success'; + + return response()->json($result); + } + /** * Update payment status (and balance). * @@ -325,4 +354,134 @@ return $mandate; } + + + /** + * 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 paymentMethodsWhitelist($type): ?array + { + switch ($type) { + case PaymentProvider::TYPE_ONEOFF: + return [ + 'creditcard' => [ + 'id' => 'creditcard', + 'icon' => ['prefix' => 'far', 'name' => 'credit-card'] + ], + 'paypal' => [ + 'id' => 'paypal', + 'icon' => ['prefix' => 'fab', 'name' => 'paypal'] + ], + 'banktransfer' => [ + 'id' => 'banktransfer', + 'icon' => ['prefix' => 'fas', 'name' => 'university'] + ] + ]; + case PaymentProvider::TYPE_RECURRING: + return [ + 'creditcard' => [ + 'id' => 'creditcard', + 'icon' => ['prefix' => 'far', 'name' => 'credit-card'] + ], + 'paypal' => [ + 'id' => 'paypal', + 'icon' => ['prefix' => 'fab', 'name' => 'paypal'] + ] + ]; + } + } + + + /** + * 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(); + + $provider = PaymentProvider::factory($wallet); + + $providerMethods = $provider->paymentMethods($request->type); + + \Log::debug("Provider methods" . var_export(json_encode($providerMethods), true)); + + $whitelistMethods = self::paymentMethodsWhitelist($request->type); + + $methods = []; + + // Use only whitelisted methods, and apply values from whitelist (overriding the backend) + foreach ($whitelistMethods as $id => $whitelistMethod) { + if (array_key_exists($id, $providerMethods)) { + $methods[] = array_merge($providerMethods[$id], $whitelistMethod); + } + } + 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', 'oneoff') + ->whereIn('status', ['open', 'pending', '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,34 @@ ]; } + + /** + * 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'], + 'redirectUrl' => $response->getCheckoutUrl(), + ]; + } + + /** * Create a new automatic payment operation. * @@ -243,7 +274,7 @@ 'description' => $payment['description'], 'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'), 'locale' => 'en_US', - // 'method' => 'creditcard', + 'method' => $payment['methodId'], 'mandateId' => $mandate->id ]; @@ -333,7 +364,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 +379,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 ]; } } @@ -510,4 +541,81 @@ 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 + * - currency: Currency used for the method + * - image|icon: An image (url) or icon (icon name) representing the method + */ + public function paymentMethods($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 non + */ + 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,71 @@ 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 + * - currency: Currency used for the method + * - image|icon: An image (url) or icon (icon name) representing the method + */ + public function paymentMethods($type): ?array + { + //TODO get this from the stripe API? + switch ($type) { + case self::TYPE_ONEOFF: + return [ + [ + 'id' => 'creditcard', + 'name' => "Credit Card", + 'minimumAmount' => "10.00", + 'currency' => 'CHF', + 'icon' => "credit-card" + ], + [ + 'id' => 'paypal', + 'name' => "PayPal", + 'minimumAmount' => "10.00", + 'currency' => 'CHF', + 'icon' => "paypal" + ] + ]; + case self::TYPE_RECURRING: + return [ + [ + 'id' => 'creditcard', + 'name' => "Credit Card", + 'minimumAmount' => "10.00", + 'currency' => 'CHF', + 'icon' => "credit-card" + ] + ]; + } + } + + /** + * 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 @@ -157,6 +157,25 @@ 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) { + \Log::error("Currency conversion is not yet implemented."); + abort(500, "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 +191,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 = $refund['amount'] * $this->exchangeRate($refund['currency'], $wallet->currency); + + $wallet->balance -= $amount; $wallet->save(); if ($refund['type'] == self::TYPE_CHARGEBACK) { @@ -185,13 +211,40 @@ '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. + * + * @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 + * - currency: Currency used for the method + * - image|icon: An image (url) or icon (icon name) representing the method + */ + abstract public function paymentMethods($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; } 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,33 @@ } } +#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; + } + + img { + width: 6rem; + height: 6rem; + margin: auto; + } + + 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 @@
+
+
+ +
+