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,410 @@ + 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('services.coinbase.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" => \config('app.name'), + "description" => $payment['description'], + "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; + //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']; + //We store in satoshis (the database stores it as INTEGER type) + $payment['currency_amount'] = $json['data']['pricing']['bitcoin']['amount'] * self::SATOSHI_MULTIPLIER; + $payment['currency'] = 'BTC'; + + $this->storePayment($payment, $wallet->id); + + return [ + 'id' => $payment['id'], + 'newWindowUrl' => $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)); + $decoded = json_decode($response->getBody(), true); + $message = ""; + if ( + is_array($decoded) && array_key_exists('error', $decoded) && + is_array($decoded['error']) && array_key_exists('message', $decoded['error']) + ) { + $message = $decoded['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 false; + } + + 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("Coinbase request 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,11 @@ $provider = PaymentProvider::factory($providerName); $methods = $provider->providerPaymentMethods($type, $wallet->currency); + + if (!empty(\config('services.coinbase.key'))) { + $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 @@ -255,7 +255,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,11 @@ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), ], + 'coinbase' => [ + 'key' => env('COINBASE_KEY'), + 'webhook_secret' => env('COINBASE_WEBHOOK_SECRET'), + 'api_verify_tls' => env('COINBASE_VERIFY_TLS', true), + ], '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 @@ -209,6 +209,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 @@ -87,9 +87,12 @@
-

+

{{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }}

+

+ {{ $t('wallet.coinbase-hint', { wc: wallet.currency }) }} +

{{ $t('wallet.banktransfer-hint') }}

@@ -101,7 +104,7 @@ {{ wallet.currency }}
-
+
{{ $t('wallet.payment-warning', { price: $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }) }}
@@ -153,6 +156,7 @@ require('@fortawesome/free-brands-svg-icons/faPaypal').definition, require('@fortawesome/free-regular-svg-icons/faCreditCard').definition, require('@fortawesome/free-solid-svg-icons/faBuildingColumns').definition, + require('@fortawesome/free-brands-svg-icons/faBitcoin').definition, ) export default { @@ -282,6 +286,9 @@ .then(response => { if (response.data.redirectUrl) { location.href = response.data.redirectUrl + } else if (response.data.newWindowUrl) { + window.open(response.data.newWindowUrl, '_blank') + this.$refs.paymentDialog.hide(); } else { this.stripeCheckout(response.data) } diff --git a/src/tests/Browser/PaymentCoinbaseTest.php b/src/tests/Browser/PaymentCoinbaseTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/PaymentCoinbaseTest.php @@ -0,0 +1,82 @@ +deleteTestUser('payment-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('payment-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test the payment process + * + * @group coinbase + */ + public function testPayment(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) use ($user) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true, ['paymentProvider' => 'mollie']) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main button', 'Add credit') + ->click('@main button') + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->waitFor('#payment-method-selection .link-bitcoin svg') + ->click('#payment-method-selection .link-bitcoin'); + }) + ->with(new Dialog('@payment-dialog'), function (Browser $browser) { + $browser->assertSeeIn('@title', 'Top up your wallet') + ->assertFocused('#amount') + ->assertSeeIn('@button-cancel', 'Cancel') + ->assertSeeIn('@button-action', 'Continue') + // Test error handling + ->type('@body #amount', 'aaa') + ->click('@button-action') + ->assertToast(Toast::TYPE_ERROR, 'Form validation error') + ->assertSeeIn('#amount + span + .invalid-feedback', 'The amount must be a number.') + // Submit valid data + ->type('@body #amount', '12.34') + // Note we use double click to assert it does not create redundant requests + ->click('@button-action') + ->click('@button-action'); + }) + ->waitUntilMissing('@payment-dialog'); + + $this->assertSame(1, $user->wallets()->first()->payments()->count()); + }); + } +} diff --git a/src/tests/CoinbaseMocksTrait.php b/src/tests/CoinbaseMocksTrait.php new file mode 100644 --- /dev/null +++ b/src/tests/CoinbaseMocksTrait.php @@ -0,0 +1,40 @@ +push( + Middleware::history($this->coinbaseRequestHistory) + ); + + \App\Providers\Payment\Coinbase::$testClient = new Client(['handler' => $handler]); + + return $mockHandler; + } + + public function unmockCoinbase() + { + \App\Providers\Payment\Coinbase::$testClient = null; + } +} diff --git a/src/tests/Feature/Controller/PaymentsCoinbaseTest.php b/src/tests/Feature/Controller/PaymentsCoinbaseTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/PaymentsCoinbaseTest.php @@ -0,0 +1,447 @@ + '']); + + Utils::setTestExchangeRates(['EUR' => '0.90503424978382']); + $john = $this->getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + $types = [ + Transaction::WALLET_CREDIT, + Transaction::WALLET_REFUND, + Transaction::WALLET_CHARGEBACK, + ]; + Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + WalletSetting::where('wallet_id', $wallet->id)->delete(); + $types = [ + Transaction::WALLET_CREDIT, + Transaction::WALLET_REFUND, + Transaction::WALLET_CHARGEBACK, + ]; + Transaction::where('object_id', $wallet->id)->whereIn('type', $types)->delete(); + Utils::setTestExchangeRates([]); + + parent::tearDown(); + } + + /** + * Test creating a payment and receiving a status via webhook + * + * @group coinbase + */ + public function testStoreAndWebhook(): void + { + Bus::fake(); + + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + // Invalid amount + $post = ['amount' => -1]; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(422); + + $json = $response->json(); + + $this->assertSame('error', $json['status']); + $this->assertCount(1, $json['errors']); + $min = $wallet->money(PaymentProvider::MIN_AMOUNT); + $this->assertSame("Minimum amount for a single payment is {$min}.", $json['errors']['amount']); + + // Invalid currency + $post = ['amount' => '12.34', 'currency' => 'FOO', 'methodId' => 'bitcoin']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(500); + + // Rate limit exceeded + $coinbase_response = [ + 'error' => [ + 'type' => 'rate_limit_exceeded', + 'message' => 'Rate limit exceeded', + ], + ]; + + $responseStack = $this->mockCoinbase(); + $responseStack->append(new Response(429, [], json_encode($coinbase_response))); + + $post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(500); + + // Rate limit exceeded + $coinbase_response = [ + 'error' => [ + 'type' => 'invalid_request', + 'message' => 'Required parameter missing: name', + ], + ]; + + $responseStack = $this->mockCoinbase(); + $responseStack->append(new Response(400, [], json_encode($coinbase_response))); + + $post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(500); + + // Successful payment + $coinbase_response = [ + 'reason' => 'Created', + 'data' => [ + 'code' => 'test123', + 'hosted_url' => 'https://commerce.coinbase.com', + 'pricing' => [ + 'bitcoin' => [ + 'amount' => 0.0000005, + ], + ], + ], + ]; + + $responseStack = $this->mockCoinbase(); + $responseStack->append(new Response(201, [], json_encode($coinbase_response))); + + $post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertMatchesRegularExpression('|^https://commerce.coinbase.com|', $json['newWindowUrl']); + + $payments = Payment::where('wallet_id', $wallet->id)->get(); + + $this->assertCount(1, $payments); + $payment = $payments[0]; + $this->assertSame(1234, $payment->amount); + $this->assertSame(5, $payment->currency_amount); + $this->assertSame('BTC', $payment->currency); + $this->assertSame($user->tenant->title . ' Payment', $payment->description); + $this->assertSame('open', $payment->status); + $this->assertEquals(0, $wallet->balance); + + // Test the webhook + $post = [ + 'event' => + [ + 'api_version' => '2018-03-22', + 'data' => [ + 'code' => $payment->id, + ], + 'type' => 'charge:resolved', + ], + ]; + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + $transaction = $wallet->transactions() + ->where('type', Transaction::WALLET_CREDIT)->get()->last(); + + $this->assertSame(1234, $transaction->amount); + $this->assertSame( + "Payment transaction {$payment->id} using Coinbase", + $transaction->description + ); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); + + // Verify "paid -> open -> paid" scenario, assert that balance didn't change + $post = [ + 'event' => + [ + 'api_version' => '2018-03-22', + 'data' => [ + 'code' => $payment->id, + ], + 'type' => 'charge:created', + ], + ]; + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + $post = [ + 'event' => + [ + 'api_version' => '2018-03-22', + 'data' => [ + 'code' => $payment->id, + ], + 'type' => 'charge:resolved', + ], + ]; + + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Test for payment failure + Bus::fake(); + + $payment->refresh(); + $payment->status = PaymentProvider::STATUS_OPEN; + $payment->save(); + + $post = [ + 'event' => + [ + 'api_version' => '2018-03-22', + 'data' => [ + 'code' => $payment->id, + ], + 'type' => 'charge:failed', + ], + ]; + + $response = $this->webhookRequest($post); + + $response->assertStatus(200); + + $this->assertSame('failed', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Assert that email notification job wasn't dispatched, + // it is expected only for recurring payments + Bus::assertDispatchedTimes(\App\Jobs\PaymentEmail::class, 0); + } + + + /** + * Test creating a payment and receiving a status via webhook using a foreign currency + * + * @group coinbase + */ + public function testStoreAndWebhookForeignCurrency(): void + { + Bus::fake(); + + $user = $this->getTestUser('john@kolab.org'); + $wallet = $user->wallets()->first(); + + // Successful payment in BTC + $coinbase_response = [ + 'reason' => 'Created', + 'data' => [ + 'code' => 'test123', + 'hosted_url' => 'www.hosted.com', + 'pricing' => [ + 'bitcoin' => [ + 'amount' => 0.0000005, + ], + ], + ], + ]; + + $responseStack = $this->mockCoinbase(); + $responseStack->append(new Response(201, [], json_encode($coinbase_response))); + $post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $payment = $wallet->payments() + ->where('currency', 'BTC')->get()->last(); + + $this->assertSame(1234, $payment->amount); + $this->assertSame(5, $payment->currency_amount); + $this->assertSame('BTC', $payment->currency); + $this->assertEquals(0, $wallet->balance); + + $post = [ + 'event' => + [ + 'api_version' => '2018-03-22', + 'data' => [ + 'code' => $payment->id, + ], + 'type' => 'charge:resolved', + ], + ]; + + $response = $this->webhookRequest($post); + $response->assertStatus(200); + + $this->assertSame(PaymentProvider::STATUS_PAID, $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + } + + + /** + * Generate Coinbase-Signature header for a webhook payload + */ + protected function webhookRequest($post) + { + $secret = \config('services.coinbase.webhook_secret'); + + $payload = json_encode($post); + $sig = \hash_hmac('sha256', $payload, $secret); + + return $this->withHeaders(['x-cc-webhook-signature' => $sig]) + ->json('POST', "api/webhooks/payment/coinbase", $post); + } + + + /** + * Test listing a pending payment + * + * @group coinbase + */ + public function testListingPayments(): void + { + Bus::fake(); + + $user = $this->getTestUser('john@kolab.org'); + + //Empty response + $response = $this->actingAs($user)->get("api/v4/payments/pending"); + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame(0, $json['count']); + $this->assertSame(1, $json['page']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(0, $json['list']); + + $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); + $json = $response->json(); + $this->assertSame(false, $json['hasPending']); + + $wallet = $user->wallets()->first(); + + // Successful payment + $coinbase_response = [ + 'reason' => 'Created', + 'data' => [ + 'code' => 'test123', + 'hosted_url' => 'www.hosted.com', + 'pricing' => [ + 'bitcoin' => [ + 'amount' => 0.0000005, + ], + ], + ], + ]; + + $responseStack = $this->mockCoinbase(); + $responseStack->append(new Response(201, [], json_encode($coinbase_response))); + $post = ['amount' => '12.34', 'currency' => 'BTC', 'methodId' => 'bitcoin']; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + //A response + $response = $this->actingAs($user)->get("api/v4/payments/pending"); + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertSame(1, $json['count']); + $this->assertSame(1, $json['page']); + $this->assertSame(false, $json['hasMore']); + $this->assertCount(1, $json['list']); + $this->assertSame(PaymentProvider::STATUS_OPEN, $json['list'][0]['status']); + $this->assertSame('CHF', $json['list'][0]['currency']); + $this->assertSame(PaymentProvider::TYPE_ONEOFF, $json['list'][0]['type']); + $this->assertSame(1234, $json['list'][0]['amount']); + + $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); + $json = $response->json(); + $this->assertSame(true, $json['hasPending']); + + // Set the payment to paid + $payments = Payment::where('wallet_id', $wallet->id)->get(); + + $this->assertCount(1, $payments); + $payment = $payments[0]; + + $payment->status = PaymentProvider::STATUS_PAID; + $payment->save(); + + // They payment should be gone from the pending list now + $response = $this->actingAs($user)->get("api/v4/payments/pending"); + $json = $response->json(); + $this->assertSame('success', $json['status']); + $this->assertSame(0, $json['count']); + $this->assertCount(0, $json['list']); + + $response = $this->actingAs($user)->get("api/v4/payments/has-pending"); + $json = $response->json(); + $this->assertSame(false, $json['hasPending']); + } + + /** + * Test listing payment methods + * + * @group coinbase + */ + public function testListingPaymentMethods(): void + { + Bus::fake(); + + $user = $this->getTestUser('john@kolab.org'); + + $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF); + $response->assertStatus(200); + $json = $response->json(); + + $this->assertCount(4, $json); + $this->assertSame('bitcoin', $json[3]['id']); + $this->assertSame('BTC', $json[3]['currency']); + + $response = $this->actingAs($user)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_RECURRING); + $response->assertStatus(200); + $json = $response->json(); + + $this->assertCount(1, $json); + } +}