diff --git a/src/app/Providers/PaymentProvider.php b/src/app/Providers/PaymentProvider.php index 5d65329c..7d778751 100644 --- a/src/app/Providers/PaymentProvider.php +++ b/src/app/Providers/PaymentProvider.php @@ -1,384 +1,384 @@ ['prefix' => 'far', 'name' => 'credit-card'], self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'], - self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'university'] + self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'building-columns'] ]; /** * Detect the name of the provider * * @param \App\Wallet|string|null $provider_or_wallet * @return string The name of the provider */ private static function providerName($provider_or_wallet = null): string { if ($provider_or_wallet instanceof Wallet) { $settings = $provider_or_wallet->getSettings(['stripe_id', 'mollie_id']); if ($settings['stripe_id']) { $provider = self::PROVIDER_STRIPE; } elseif ($settings['mollie_id']) { $provider = self::PROVIDER_MOLLIE; } } else { $provider = $provider_or_wallet; } if (empty($provider)) { $provider = \config('services.payment_provider') ?: self::PROVIDER_MOLLIE; } 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 self::PROVIDER_MOLLIE: return new \App\Providers\Payment\Mollie(); default: throw new \Exception("Invalid payment provider: {$provider_or_wallet}"); } } /** * Create a new auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - description: Operation desc. * - methodId: Payment method * * @return array Provider payment data: * - id: Operation identifier * - redirectUrl: the location to redirect to */ abstract public function createMandate(Wallet $wallet, array $payment): ?array; /** * Revoke the auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @return bool True on success, False on failure */ abstract public function deleteMandate(Wallet $wallet): bool; /** * Get a auto-payment mandate for a wallet. * * @param \App\Wallet $wallet The wallet * * @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 */ abstract public function getMandate(Wallet $wallet): ?array; /** * Get a link to the customer in the provider's control panel * * @param \App\Wallet $wallet The wallet * * @return string|null The string representing tag */ abstract public function customerLink(Wallet $wallet): ?string; /** * Get a provider name * * @return string Provider name */ abstract public function name(): string; /** * Create a new payment. * * @param \App\Wallet $wallet The wallet * @param array $payment Payment data: * - amount: Value in cents * - currency: The operation currency * - type: first/oneoff/recurring * - description: Operation description * - methodId: Payment method * * @return array Provider payment/session data: * - id: Operation identifier * - redirectUrl */ abstract public function payment(Wallet $wallet, array $payment): ?array; /** * Update payment status (and balance). * * @return int HTTP response code */ abstract public function webhook(): int; /** * Create a payment record in DB * * @param array $payment Payment information * @param string $wallet_id Wallet ID * * @return \App\Payment Payment object */ protected function storePayment(array $payment, $wallet_id): Payment { $db_payment = new Payment(); $db_payment->id = $payment['id']; $db_payment->description = $payment['description'] ?? ''; $db_payment->status = $payment['status'] ?? self::STATUS_OPEN; $db_payment->amount = $payment['amount'] ?? 0; $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; } /** * 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 * \App\Utils::exchangeRate($sourceCurrency, $targetCurrency))); } /** * Deduct an amount of pecunia from the wallet. * Creates a payment and transaction records for the refund/chargeback operation. * * @param \App\Wallet $wallet A wallet object * @param array $refund A refund or chargeback data (id, type, amount, description) * * @return void */ protected function storeRefund(Wallet $wallet, array $refund): void { if (empty($refund) || empty($refund['amount'])) { return; } // Preserve originally refunded amount $refund['currency_amount'] = $refund['amount'] * -1; // 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) { $transaction_type = Transaction::WALLET_CHARGEBACK; } else { $transaction_type = Transaction::WALLET_REFUND; } Transaction::create([ 'object_id' => $wallet->id, 'object_type' => Wallet::class, 'type' => $transaction_type, 'amount' => $amount * -1, 'description' => $refund['description'] ?? '', ]); $refund['status'] = self::STATUS_PAID; $refund['amount'] = -1 * $amount; // FIXME: Refunds/chargebacks are out of the reseller comissioning for now $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). * @param string $currency Currency code * * @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(string $type, string $currency): 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 { $methods = []; switch ($type) { case self::TYPE_ONEOFF: $methods = explode(',', \config('app.payment.methods_oneoff')); break; case PaymentProvider::TYPE_RECURRING: $methods = explode(',', \config('app.payment.methods_recurring')); break; default: \Log::error("Unknown payment type: " . $type); } $methods = array_map('strtolower', array_map('trim', $methods)); return $methods; } /** * 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) { if (array_key_exists($id, $availableMethods)) { $method = $availableMethods[$id]; $method['icon'] = self::$paymentMethodIcons[$id]; $methods[] = $method; } } 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}-{$wallet->currency}"; if ($methods = Cache::get($cacheKey)) { \Log::debug("Using payment method cache" . var_export($methods, true)); return $methods; } $provider = PaymentProvider::factory($providerName); $methods = $provider->providerPaymentMethods($type, $wallet->currency); $methods = self::applyMethodWhitelist($type, $methods); \Log::debug("Loaded payment methods" . var_export($methods, true)); Cache::put($cacheKey, $methods, now()->addHours(1)); return $methods; } /** * Returns the full URL for the wallet page, used when returning from an external payment page. * Depending on the request origin it will return a URL for the User or Reseller UI. * * @return string The redirect URL */ public static function redirectUrl(): string { $url = \App\Utils::serviceUrl('/wallet'); $domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost()); if (strpos($domain, 'reseller') === 0) { $url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url); } return $url; } } diff --git a/src/resources/themes/app.scss b/src/resources/themes/app.scss index 68c8eb19..f263b42b 100644 --- a/src/resources/themes/app.scss +++ b/src/resources/themes/app.scss @@ -1,510 +1,514 @@ html, body, body > .outer-container { height: 100%; } #app { display: flex; flex-direction: column; min-height: 100%; overflow: hidden; & > nav { flex-shrink: 0; z-index: 12; } & > div.container { flex-grow: 1; margin-top: 2rem; margin-bottom: 2rem; } & > .filler { flex-grow: 1; } & > div.container + .filler { display: none; } } .error-page { position: absolute; top: 0; height: 100%; width: 100%; align-content: center; align-items: center; display: flex; flex-wrap: wrap; justify-content: center; color: #636b6f; z-index: 10; background: white; .code { text-align: right; border-right: 2px solid; font-size: 26px; padding: 0 15px; } .message { font-size: 18px; padding: 0 15px; } .hint { margin-top: 3em; text-align: center; width: 100%; } } .app-loader { background-color: $body-bg; height: 100%; width: 100%; position: absolute; top: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 8; .spinner-border { width: 120px; height: 120px; border-width: 15px; color: #b2aa99; } &.small .spinner-border { width: 25px; height: 25px; border-width: 3px; } &.fadeOut { visibility: hidden; opacity: 0; transition: visibility 300ms linear, opacity 300ms linear; } } pre { margin: 1rem 0; padding: 1rem; background-color: $menu-bg-color; } .card-title { font-size: 1.2rem; font-weight: bold; } tfoot.table-fake-body { background-color: #f8f8f8; color: grey; text-align: center; td { vertical-align: middle; height: 8em; border: 0; } tbody:not(:empty) + & { display: none; } } table { th { white-space: nowrap; } td.email, td.price, td.datetime, td.selection { width: 1%; white-space: nowrap; } td.buttons, th.price, td.price, th.size, td.size { width: 1%; text-align: right; white-space: nowrap; } &.form-list { margin: 0; td { border: 0; &:first-child { padding-left: 0; } &:last-child { padding-right: 0; } } button { line-height: 1; } } .btn-action { line-height: 1; padding: 0; } td { & > svg + a, & > svg + span { margin-left: .4em; } } &.files { table-layout: fixed; td { white-space: nowrap; } td.name { overflow: hidden; text-overflow: ellipsis; } /* td.size, th.size { width: 80px; } td.mtime, th.mtime { width: 140px; @include media-breakpoint-down(sm) { display: none; } } */ td.buttons, th.buttons { width: 50px; } } } .table > :not(:first-child) { // Remove Bootstrap's 2px border border-width: 0; } .list-details { min-height: 1em; & > ul { margin: 0; padding-left: 1.2em; } } .plan-selector { .plan-header { display: flex; } .plan-ico { margin:auto; font-size: 3.8rem; color: #f1a539; border: 3px solid #f1a539; width: 6rem; height: 6rem; border-radius: 50%; } } .status-message { display: flex; align-items: center; justify-content: center; .app-loader { width: auto; position: initial; .spinner-border { color: $body-color; } } svg { font-size: 1.5em; } :first-child { margin-right: 0.4em; } } .form-separator { position: relative; margin: 1em 0; display: flex; justify-content: center; hr { border-color: #999; margin: 0; position: absolute; top: 0.75em; width: 100%; } span { background: #fff; padding: 0 1em; z-index: 1; } } #status-box { background-color: lighten($green, 35); .progress { background-color: #fff; height: 10px; } .progress-label { font-size: 0.9em; } .progress-bar { background-color: $green; } &.process-failed { background-color: lighten($orange, 30); .progress-bar { background-color: $red; } } } @keyframes blinker { 50% { opacity: 0; } } .blinker { animation: blinker 750ms step-start infinite; } #dashboard-nav { 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; &.disabled { pointer-events: none; opacity: 0.6; } // Some icons are too big, scale them down &.link-companionapp, &.link-domains, &.link-resources, &.link-settings, &.link-wallet, &.link-invitations { svg { transform: scale(0.8); } } &.link-distlists, &.link-files, &.link-shared-folders { svg { transform: scale(0.9); } } .badge { position: absolute; top: 0.5rem; right: 0.5rem; } } svg { width: 6rem; height: 6rem; margin: auto; } } #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; } + + .link-banktransfer svg { + transform: scale(.8); + } } #logon-form { flex-basis: auto; // Bootstrap issue? See logon page with width < 992 } #logon-form-footer { a:not(:first-child) { margin-left: 2em; } } // Various improvements for mobile @include media-breakpoint-down(sm) { .card, .card-footer { border: 0; } .card-body { padding: 0.5rem 0; } .nav-tabs { flex-wrap: nowrap; .nav-link { white-space: nowrap; padding: 0.5rem 0.75rem; } } #app > div.container { margin-bottom: 1rem; margin-top: 1rem; max-width: 100%; } #header-menu-navbar { padding: 0; } #dashboard-nav > a { width: 135px; } .table-sm:not(.form-list) { tbody td { padding: 0.75rem 0.5rem; svg { vertical-align: -0.175em; } & > svg { font-size: 125%; margin-right: 0.25rem; } } } .table.transactions { thead { display: none; } tbody { tr { position: relative; display: flex; flex-wrap: wrap; } td { width: auto; border: 0; padding: 0.5rem; &.datetime { width: 50%; padding-left: 0; } &.description { order: 3; width: 100%; border-bottom: 1px solid $border-color; color: $secondary; padding: 0 1.5em 0.5rem 0; margin-top: -0.25em; } &.selection { position: absolute; right: 0; border: 0; top: 1.7em; padding-right: 0; } &.price { width: 50%; padding-right: 0; } &.email { display: none; } } } } } @include media-breakpoint-down(sm) { .tab-pane > .card-body { padding: 0.5rem; } } diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index 32b5a88b..1b506553 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,446 +1,446 @@