Page MenuHomePhorge

D3578.id10277.diff
No OneTemporary

D3578.id10277.diff

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 @@
+<?php
+
+namespace App\Providers\Payment;
+
+use App\Payment;
+use App\Utils;
+use App\Wallet;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Request;
+
+class Coinbase extends \App\Providers\PaymentProvider
+{
+ /** @var \GuzzleHttp\Client|null HTTP client instance */
+ private $client = null;
+ /** @var \GuzzleHttp\Client|null test HTTP client instance */
+ public static $testClient = null;
+
+
+ /**
+ * Get a link to the customer in the provider's control panel
+ *
+ * @param \App\Wallet $wallet The wallet
+ *
+ * @return string|null The string representing <a> 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 @@
</form>
</div>
<div id="manual-payment" v-if="paymentForm == 'manual'">
- <p v-if="wallet.currency != selectedPaymentMethod.currency">
+ <p v-if="wallet.currency != selectedPaymentMethod.currency && selectedPaymentMethod.id != 'bitcoin'">
{{ $t('wallet.currency-conv', { wc: wallet.currency, pc: selectedPaymentMethod.currency }) }}
</p>
+ <p v-if="selectedPaymentMethod.id == 'bitcoin'">
+ {{ $t('wallet.coinbase-hint', { wc: wallet.currency }) }}
+ </p>
<p v-if="selectedPaymentMethod.id == 'banktransfer'">
{{ $t('wallet.banktransfer-hint') }}
</p>
@@ -123,7 +126,7 @@
<input type="text" class="form-control" id="amount" v-model="amount" required>
<span class="input-group-text">{{ wallet.currency }}</span>
</div>
- <div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount)" class="alert alert-warning m-0 mt-3">
+ <div v-if="wallet.currency != selectedPaymentMethod.currency && !isNaN(amount) && selectedPaymentMethod.exchangeRate" class="alert alert-warning m-0 mt-3">
{{ $t('wallet.payment-warning', { price: $root.price(amount * selectedPaymentMethod.exchangeRate * 100, selectedPaymentMethod.currency) }) }}
</div>
</form>
@@ -195,6 +198,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 {
@@ -313,6 +317,9 @@
.then(response => {
if (response.data.redirectUrl) {
location.href = response.data.redirectUrl
+ } else if (response.data.newWindow) {
+ window.open(response.data.newWindow, '_blank')
+ this.dialog.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 @@
+<?php
+
+namespace Tests\Browser;
+
+use App\Providers\PaymentProvider;
+use App\Wallet;
+use Tests\Browser;
+use Tests\Browser\Components\Dialog;
+use Tests\Browser\Components\Toast;
+use Tests\Browser\Pages\Dashboard;
+use Tests\Browser\Pages\Home;
+use Tests\Browser\Pages\Wallet as WalletPage;
+use Tests\TestCaseDusk;
+
+class PaymentCoinbaseTest extends TestCaseDusk
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ $this->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 @@
+<?php
+
+namespace Tests;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Middleware;
+
+trait CoinbaseMocksTrait
+{
+ public $coinbaseRequestHistory = [];
+
+ /**
+ * Make Coinbase's Guzzle instance use a mock handler.
+ *
+ * @see http://docs.guzzlephp.org/en/stable/testing.html
+ *
+ * @return \GuzzleHttp\Handler\MockHandler
+ */
+ public function mockCoinbase()
+ {
+ $handler = HandlerStack::create(
+ $mockHandler = new MockHandler()
+ );
+
+ $handler->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,416 @@
+<?php
+
+namespace Tests\Feature\Controller;
+
+use App\Http\Controllers\API\V4\PaymentsController;
+use App\Payment;
+use App\Providers\PaymentProvider;
+use App\Transaction;
+use App\Wallet;
+use App\WalletSetting;
+use App\Utils;
+use GuzzleHttp\Psr7\Response;
+use Illuminate\Support\Facades\Bus;
+use Tests\TestCase;
+use Tests\BrowserAddonTrait;
+use Tests\CoinbaseMocksTrait;
+
+class PaymentsCoinbaseTest extends TestCase
+{
+ use CoinbaseMocksTrait;
+ use BrowserAddonTrait;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ \config(['services.payment_provider' => '']);
+
+ 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);
+
+ // Successful payment
+ $coinbase_response = [
+ 'reason' => 'Created',
+ 'data' => [
+ 'code' => 'test123',
+ 'hosted_url' => 'https://commerce.coinbase.com',
+ 'pricing' => [
+ 'bitcoin' => [
+ 'amount' => 5,
+ ],
+ ],
+ ],
+ ];
+
+ $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['newWindow']);
+
+ $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' => 5,
+ ],
+ ],
+ ],
+ ];
+
+ $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' => 5,
+ ],
+ ],
+ ],
+ ];
+
+ $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);
+ }
+}

File Metadata

Mime Type
text/plain
Expires
Thu, Oct 31, 7:52 AM (1 h, 28 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
10074549
Default Alt Text
D3578.id10277.diff (40 KB)

Event Timeline