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', ]; @@ -232,6 +235,40 @@ } /** + * Delete a pending payment. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + // TODO currently unused + // public function cancel(Request $request) + // { + // $current_user = Auth::guard()->user(); + + // // TODO: Wallet selection + // $wallet = $current_user->wallets()->first(); + + // $paymentId = $request->payment; + + // $user_owns_payment = Payment::where('id', $paymentId) + // ->where('wallet_id', $wallet->id) + // ->exists(); + + // if (!$user_owns_payment) { + // return $this->errorResponse(404); + // } + + // $provider = PaymentProvider::factory($wallet); + // if ($provider->cancel($wallet, $paymentId)) { + // $result = ['status' => 'success']; + // return response()->json($result); + // } + + // return $this->errorResponse(404); + // } + + /** * Update payment status (and balance). * * @param string $provider Provider name @@ -291,6 +328,7 @@ 'type' => PaymentProvider::TYPE_RECURRING, 'currency' => 'CHF', 'amount' => $amount, + 'methodId' => PaymentProvider::METHOD_CREDITCARD, 'description' => \config('app.name') . ' Recurring Payment', ]; @@ -325,4 +363,113 @@ 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); + } + + /** + * Check for pending payments. + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public static function hasPayments(Request $request) + { + $user = Auth::guard()->user(); + + // TODO: Wallet selection + $wallet = $user->wallets()->first(); + + $exists = Payment::where('wallet_id', $wallet->id) + ->where('type', PaymentProvider::TYPE_ONEOFF) + ->whereIn('status', [ + PaymentProvider::STATUS_OPEN, + PaymentProvider::STATUS_PENDING, + PaymentProvider::STATUS_AUTHORIZED]) + ->exists(); + + return response()->json([ + 'status' => 'success', + 'hasPending' => $exists + ]); + } + + /** + * 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(); + + $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]) + ->orderBy('created_at', 'desc') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + $result = $result->map(function ($item) { + $provider = PaymentProvider::factory($item->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 string $currency Currency of this payment + * @property int $currency_amount Amount of money in cents of $currency */ class Payment extends Model { @@ -30,8 +32,19 @@ 'provider', 'status', 'type', + 'currency', + 'currency_amount', ]; + + /** + * Ensure the currency is appropriately cased. + */ + public function setCurrencyAttribute($currency) + { + $this->attributes['currency'] = strtoupper($currency); + } + /** * The wallet to which this payment belongs. * 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 @@ -41,6 +41,7 @@ * - amount: Value in cents (optional) * - currency: The operation currency * - description: Operation desc. + * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier @@ -55,10 +56,13 @@ $payment['amount'] = 0; } + $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); + $payment['currency_amount'] = $amount; + $request = [ 'amount' => [ 'currency' => $payment['currency'], - 'value' => sprintf('%.2f', $payment['amount'] / 100), + 'value' => sprintf('%.2f', $amount / 100), ], 'customerId' => $customer_id, 'sequenceType' => 'first', @@ -66,7 +70,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 @@ -119,6 +123,7 @@ * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. + * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ @@ -135,7 +140,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; @@ -160,6 +166,7 @@ * - currency: The operation currency * - type: oneoff/recurring * - description: Operation desc. + * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier @@ -174,20 +181,23 @@ // Register the user in Mollie, if not yet done $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 = [ 'amount' => [ 'currency' => $payment['currency'], - // a number with two decimals is required - 'value' => sprintf('%.2f', $payment['amount'] / 100), + // a number with two decimals is required (note that JPK and ISK don't require decimals, + // but we're not using them currently) + '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 +220,27 @@ ]; } + + /** + * 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); + $db_payment->status = $response->status; + $db_payment->save(); + + return true; + } + + /** * Create a new automatic payment operation. * @@ -231,19 +262,21 @@ $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; $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'], 'mandateId' => $mandate->id ]; @@ -333,7 +366,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 +381,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 +521,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 +534,89 @@ $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 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, + '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; + } + + /** + * 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 + */ + 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 @@ -83,6 +83,7 @@ $session = StripeAPI\Checkout\Session::create($request); $payment['amount'] = 0; + $payment['currency_amount'] = 0; $payment['id'] = $session->setup_intent; $payment['type'] = self::TYPE_MANDATE; @@ -181,6 +182,10 @@ // Register the user in Stripe, if not yet done $customer_id = self::stripeCustomerId($wallet, true); + + $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); + $payment['currency_amount'] = $amount; + $request = [ 'customer' => $customer_id, 'cancel_url' => Utils::serviceUrl('/wallet'), // required @@ -190,7 +195,7 @@ 'line_items' => [ [ 'name' => $payment['description'], - 'amount' => $payment['amount'], + 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'quantity' => 1, ] @@ -227,8 +232,11 @@ return null; } + $amount = $this->exchange($payment['amount'], $wallet->currency, $payment['currency']); + $payment['currency_amount'] = $amount; + $request = [ - 'amount' => $payment['amount'], + 'amount' => $amount, 'currency' => \strtolower($payment['currency']), 'description' => $payment['description'], 'receipt_email' => $wallet->owner->email, @@ -271,6 +279,7 @@ \config('services.stripe.webhook_secret') ); } catch (\Exception $e) { + \Log::error("Invalid payload: " . $e->getMessage()); // Invalid payload return 400; } @@ -470,4 +479,80 @@ 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 + { + //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 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 + */ + public function getPayment($paymentId): array + { + \Log::info("Stripe::getPayment does not yet retrieve a checkoutUrl."); + + $payment = StripeAPI\PaymentIntent::retrieve($paymentId); + return [ + 'id' => $payment->id, + 'status' => $payment->status, + '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,39 +23,64 @@ 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; + 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 + * @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: - throw new \Exception("Invalid payment provider: {$provider}"); + throw new \Exception("Invalid payment provider: {$provider_or_wallet}"); } } @@ -66,6 +92,7 @@ * - amount: Value in cents * - currency: The operation currency * - description: Operation desc. + * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier @@ -90,6 +117,7 @@ * @return array|null Mandate information: * - id: Mandate identifier * - method: user-friendly payment method desc. + * - methodId: Payment method * - isPending: the process didn't complete yet * - isValid: the mandate is valid */ @@ -120,6 +148,7 @@ * - currency: The operation currency * - type: first/oneoff/recurring * - description: Operation description + * - methodId: Payment method * * @return array Provider payment/session data: * - id: Operation identifier @@ -152,12 +181,45 @@ $db_payment->type = $payment['type']; $db_payment->wallet_id = $wallet_id; $db_payment->provider = $this->name(); + $db_payment->currency = $payment['currency']; + $db_payment->currency_amount = $payment['currency_amount']; $db_payment->save(); 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."); + //FIXME Not yet implemented + } + return 1.0; + } + + /** + * Convert a value from $sourceCurrency to $targetCurrency + * + * @param int $amount Amount in cents of $sourceCurrency + * @param string $sourceCurrency Currency from which to convert + * @param string $targetCurrency Currency to convert to + * + * @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. * Creates a payment and transaction records for the refund/chargeback operation. * @@ -172,7 +234,14 @@ 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; $wallet->save(); if ($refund['type'] == self::TYPE_CHARGEBACK) { @@ -185,13 +254,135 @@ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $transaction_type, - 'amount' => $refund['amount'] * -1, + 'amount' => $amount * -1, '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 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 + */ + 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 + // 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] + ] + ]; + } + + \Log::error("Unknown payment type: " . $type); + return []; + } + + /** + * 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: + * - 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,55 @@ +string('currency')->nullable(); + $table->integer('currency_amount')->nullable(); + } + ); + + DB::table('payments')->update([ + 'currency' => 'CHF', + 'currency_amount' => DB::raw("`amount`") + ]); + + Schema::table( + 'payments', + function (Blueprint $table) { + $table->string('currency')->nullable(false)->change(); + $table->integer('currency_amount')->nullable(false)->change(); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table( + 'payments', + function (Blueprint $table) { + $table->dropColumn('currency'); + $table->dropColumn('currency_amount'); + } + ); + } +} diff --git a/src/resources/js/app.js b/src/resources/js/app.js --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -170,8 +170,8 @@ return `${this.appName}` }, // Display "loading" overlay inside of the specified element - addLoader(elem) { - $(elem).css({position: 'relative'}).append($(loader).addClass('small')) + addLoader(elem, small = true) { + $(elem).css({position: 'relative'}).append(small ? $(loader).addClass('small') : $(loader)) }, // Remove loader element added in addLoader() removeLoader(elem) { 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; + + & > a { + 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,44 @@
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 "Pending 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,9 +58,14 @@ History +
-
+

@@ -48,11 +89,16 @@

-
+
+
+
+ +
+