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 @@ -217,15 +217,17 @@ return response()->json(['status' => 'error', 'errors' => $errors], 422); } + $currency = $request->currency; + $request = [ 'type' => PaymentProvider::TYPE_ONEOFF, - 'currency' => $request->currency, + 'currency' => $currency, 'amount' => $amount, 'methodId' => $request->methodId, 'description' => Tenant::getConfig($user->tenant_id, 'app.name') . ' Payment', ]; - $provider = PaymentProvider::factory($wallet); + $provider = PaymentProvider::factory($wallet, $currency); $result = $provider->payment($wallet, $request); diff --git a/src/app/Providers/Payment/Coinbase.php b/src/app/Providers/Payment/Coinbase.php new file mode 100644 --- /dev/null +++ b/src/app/Providers/Payment/Coinbase.php @@ -0,0 +1,399 @@ + tag + */ + public function customerLink(Wallet $wallet): ?string + { + return null; + } + + /** + * Create a new auto-payment mandate for a wallet. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents (optional) + * - currency: The operation currency + * - description: Operation desc. + * - methodId: Payment method + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + public function createMandate(Wallet $wallet, array $payment): ?array + { + throw new \Exception("not implemented"); + } + + /** + * Revoke the auto-payment mandate for the wallet. + * + * @param \App\Wallet $wallet The wallet + * + * @return bool True on success, False on failure + */ + public function deleteMandate(Wallet $wallet): bool + { + throw new \Exception("not implemented"); + } + + /** + * Get a auto-payment mandate for the 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 + */ + public function getMandate(Wallet $wallet): ?array + { + throw new \Exception("not implemented"); + } + + /** + * Get a provider name + * + * @return string Provider name + */ + public function name(): string + { + return 'coinbase'; + } + + /** + * Creates HTTP client for connections to coinbase + * + * @return \GuzzleHttp\Client HTTP client instance + */ + private function client() + { + if (self::$testClient) { + return self::$testClient; + } + if (!$this->client) { + $this->client = new \GuzzleHttp\Client( + [ + 'http_errors' => false, // No exceptions from Guzzle + 'base_uri' => 'https://api.commerce.coinbase.com/', + 'verify' => \config('meet.api_verify_tls'), + 'headers' => [ + 'X-CC-Api-Key' => \config('services.coinbase.key'), + 'X-CC-Version' => '2018-03-22', + ], + 'connect_timeout' => 10, + 'timeout' => 10, + 'on_stats' => function (\GuzzleHttp\TransferStats $stats) { + $threshold = \config('logging.slow_log'); + if ($threshold && ($sec = $stats->getTransferTime()) > $threshold) { + $url = $stats->getEffectiveUri(); + $method = $stats->getRequest()->getMethod(); + \Log::warning(sprintf("[STATS] %s %s: %.4f sec.", $method, $url, $sec)); + } + }, + ] + ); + } + return $this->client; + } + + /** + * Create a new payment. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data: + * - amount: Value in cents + * - currency: The operation currency + * - type: oneoff/recurring + * - description: Operation desc. + * - methodId: Payment method + * + * @return array Provider payment data: + * - id: Operation identifier + * - redirectUrl: the location to redirect to + */ + public function payment(Wallet $wallet, array $payment): ?array + { + if ($payment['type'] == self::TYPE_RECURRING) { + throw new \Exception("not supported"); + } + + $amount = $payment['amount'] / 100; + + $post = [ + 'json' => [ + "name" => "Kolab Now", + "description" => "Invoice for your Kolab Now subscription.", + "pricing_type" => "fixed_price", + 'local_price' => [ + 'currency' => $wallet->currency, + 'amount' => sprintf('%.2f', $amount), + ], + 'redirect_url' => self::redirectUrl() + ] + ]; + + $response = $this->client()->request('POST', '/charges/', $post); + + $code = $response->getStatusCode(); + if ($code == 429) { + $this->logError("Ratelimiting", $response); + throw new \Exception("Failed to create coinbase charge due to rate-limiting: {$code}"); + } + if ($code !== 201) { + $this->logError("Failed to create coinbase charge", $response); + throw new \Exception("Failed to create coinbase charge: {$code}"); + } + + $json = json_decode($response->getBody(), true); + + // Store the payment reference in database + $payment['status'] = self::STATUS_OPEN; //$response->status; + //We take the code instead of the id because it fits into our current db schema and the id doesn't + $payment['id'] = $json['data']['code']; + $payment['currency_amount'] = $json['data']['pricing']['bitcoin']['amount']; + $payment['currency'] = 'BTC'; + + $this->storePayment($payment, $wallet->id); + + return [ + 'id' => $payment['id'], + 'newWindow' => $json['data']['hosted_url'] + ]; + } + + + /** + * Log an error for a failed request to the meet server + * + * @param string $str The error string + * @param object $response Guzzle client response + */ + private function logError(string $str, $response) + { + $code = $response->getStatusCode(); + if ($code != 200 && $code != 201) { + \Log::error(var_export($response)); + $message = json_decode($response->getBody(), true)['error']['message']; + \Log::error("$str [$code]: $message"); + } + } + + + /** + * 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 = $this->client()->request('POST', "/charges/{$paymentId}/cancel"); + + if ($response->getStatusCode() == 200) { + $db_payment = Payment::find($paymentId); + $db_payment->status = self::STATUS_CANCELED; + $db_payment->save(); + } else { + $this->logError("Failed to cancel payment", $response); + } + + return true; + } + + + /** + * Create a new automatic payment operation. + * + * @param \App\Wallet $wallet The wallet + * @param array $payment Payment data (see self::payment()) + * + * @return array Provider payment/session data: + * - id: Operation identifier + */ + protected function paymentRecurring(Wallet $wallet, array $payment): ?array + { + throw new \Exception("not available with coinbase"); + } + + + private static function verifySignature($payload, $sigHeader) + { + $secret = \config('services.coinbase.webhook_secret'); + $computedSignature = \hash_hmac('sha256', $payload, $secret); + + if (!\hash_equals($sigHeader, $computedSignature)) { + throw new \Exception("Signature verification failed"); + } + } + + /** + * Update payment status (and balance). + * + * @return int HTTP response code + */ + public function webhook(): int + { + // We cannot just use php://input as it's already "emptied" by the framework + $request = Request::instance(); + $payload = $request->getContent(); + $sigHeader = $request->header('X-CC-Webhook-Signature'); + + self::verifySignature($payload, $sigHeader); + + $data = \json_decode($payload, true); + $event = $data['event']; + + $type = $event['type']; + \Log::info("Coinbase webhook called " . $type); + + if ($type == 'charge:created') { + return 200; + } + if ($type == 'charge:confirmed') { + return 200; + } + if ($type == 'charge:pending') { + return 200; + } + + $payment_id = $event['data']['code']; + + if (empty($payment_id)) { + \Log::warning(sprintf('Failed to find the payment for (%s)', $payment_id)); + return 200; + } + + $payment = Payment::find($payment_id); + + if (empty($payment)) { + return 200; + } + + $newStatus = self::STATUS_PENDING; + + // Even if we receive the payment delayed, we still have the money, and therefore credit it. + if ($type == 'charge:resolved' || $type == 'charge:delayed') { + // The payment is paid. Update the balance + if ($payment->status != self::STATUS_PAID && $payment->amount > 0) { + $credit = true; + } + $newStatus = self::STATUS_PAID; + } elseif ($type == 'charge:failed') { + // Note: I didn't find a way to get any description of the problem with a payment + \Log::info(sprintf('Coinbase payment failed (%s)', $payment->id)); + $newStatus = self::STATUS_FAILED; + } + + DB::beginTransaction(); + + // This is a sanity check, just in case the payment provider api + // sent us open -> paid -> open -> paid. So, we lock the payment after + // recivied a "final" state. + $pending_states = [self::STATUS_OPEN, self::STATUS_PENDING, self::STATUS_AUTHORIZED]; + if (in_array($payment->status, $pending_states)) { + $payment->status = $newStatus; + $payment->save(); + } + + if (!empty($credit)) { + self::creditPayment($payment); + } + + DB::commit(); + + return 200; + } + + /** + * Apply the successful payment's pecunia to the wallet + */ + protected static function creditPayment($payment) + { + // TODO: Localization? + $description = 'Payment'; + $description .= " transaction {$payment->id} using Coinbase"; + + $payment->wallet->credit($payment->amount, $description); + } + + /** + * List supported payment methods. + * + * @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 + */ + public function providerPaymentMethods(string $type, string $currency): array + { + $availableMethods = []; + + if ($type == self::TYPE_ONEOFF) { + $availableMethods['bitcoin'] = [ + 'id' => 'bitcoin', + 'name' => "Bitcoin", + 'minimumAmount' => 0.001, + 'currency' => 'BTC' + ]; + } + + 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 = Payment::find($paymentId); + + return [ + 'id' => $payment->id, + 'status' => $payment->status, + 'isCancelable' => true, + 'checkoutUrl' => "https://commerce.coinbase.com/charges/{$paymentId}" + ]; + } +} 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 @@ -27,9 +27,11 @@ public const METHOD_PAYPAL = 'paypal'; public const METHOD_BANKTRANSFER = 'banktransfer'; public const METHOD_DIRECTDEBIT = 'directdebit'; + public const METHOD_BITCOIN = 'bitcoin'; public const PROVIDER_MOLLIE = 'mollie'; public const PROVIDER_STRIPE = 'stripe'; + public const PROVIDER_COINBASE = 'coinbase'; /** const int Minimum amount of money in a single payment (in cents) */ public const MIN_AMOUNT = 1000; @@ -37,7 +39,8 @@ private static $paymentMethodIcons = [ self::METHOD_CREDITCARD => ['prefix' => 'far', 'name' => 'credit-card'], self::METHOD_PAYPAL => ['prefix' => 'fab', 'name' => 'paypal'], - self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'building-columns'] + self::METHOD_BANKTRANSFER => ['prefix' => 'fas', 'name' => 'building-columns'], + self::METHOD_BITCOIN => ['prefix' => 'fab', 'name' => 'bitcoin'], ]; /** @@ -72,8 +75,11 @@ * * @param \App\Wallet|string|null $provider_or_wallet */ - public static function factory($provider_or_wallet = null) + public static function factory($provider_or_wallet = null, $currency = null) { + if (\strtolower($currency) == 'btc') { + return new \App\Providers\Payment\Coinbase(); + } switch (self::providerName($provider_or_wallet)) { case self::PROVIDER_STRIPE: return new \App\Providers\Payment\Stripe(); @@ -81,6 +87,9 @@ case self::PROVIDER_MOLLIE: return new \App\Providers\Payment\Mollie(); + case self::PROVIDER_COINBASE: + return new \App\Providers\Payment\Coinbase(); + default: throw new \Exception("Invalid payment provider: {$provider_or_wallet}"); } @@ -355,6 +364,9 @@ $provider = PaymentProvider::factory($providerName); $methods = $provider->providerPaymentMethods($type, $wallet->currency); + + $coinbaseProvider = PaymentProvider::factory(self::PROVIDER_COINBASE); + $methods = array_merge($methods, $coinbaseProvider->providerPaymentMethods($type, $wallet->currency)); $methods = self::applyMethodWhitelist($type, $methods); \Log::debug("Loaded payment methods" . var_export($methods, true)); diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -253,7 +253,7 @@ 'password_policy' => env('PASSWORD_POLICY') ?: 'min:6,max:255', 'payment' => [ - 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer'), + 'methods_oneoff' => env('PAYMENT_METHODS_ONEOFF', 'creditcard,paypal,banktransfer,bitcoin'), 'methods_recurring' => env('PAYMENT_METHODS_RECURRING', 'creditcard'), ], diff --git a/src/config/services.php b/src/config/services.php --- a/src/config/services.php +++ b/src/config/services.php @@ -46,6 +46,10 @@ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], + 'coinbase' => [ + 'key' => env('COINBASE_KEY'), + 'webhook_secret' => env('COINBASE_WEBHOOK_SECRET'), + ], 'openexchangerates' => [ 'api_key' => env('OPENEXCHANGERATES_API_KEY', null), 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 @@ -285,6 +285,9 @@ }) }, price(price, currency) { + if (currency.toLowerCase() == 'btc') { + return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: 'BTC', minimumFractionDigits: 6, maximumFractionDigits: 9}) + } // TODO: Set locale argument according to the currently used locale return ((price || 0) / 100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -510,6 +510,8 @@ 'auto-payment-disabled-next' => "The auto-payment is disabled. Immediately after you submit new settings we'll enable it and attempt to top up your wallet.", 'auto-payment-update' => "Update auto-payment", 'banktransfer-hint' => "Please note that a bank transfer can take several days to complete.", + 'coinbase-hint' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}" + . " We will then create a charge on coinbase for the specified amount that you can pay using Bitcoin.", 'currency-conv' => "Here is how it works: You specify the amount by which you want to top up your wallet in {wc}." . " We will then convert this to {pc}, and on the next page you will be provided with the bank-details to transfer the amount in {pc}.", 'fill-up' => "Fill up by", 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 @@ -109,9 +109,12 @@
+
{{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }}
++ {{ $t('wallet.coinbase-hint', { wc: wallet.currency }) }} +
{{ $t('wallet.banktransfer-hint') }}
@@ -123,7 +126,7 @@ {{ wallet.currency }}