diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -3,6 +3,7 @@ APP_KEY= APP_DEBUG=true APP_URL=http://127.0.0.1:8000 +APP_PUBLIC_URL= APP_DOMAIN=kolabnow.com LOG_CHANNEL=stack @@ -54,6 +55,8 @@ SWOOLE_HTTP_HOST=127.0.0.1 SWOOLE_HTTP_PORT=8000 +MOLLIE_KEY= + MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 diff --git a/src/app/Console/Commands/WalletCharge.php b/src/app/Console/Commands/WalletCharge.php --- a/src/app/Console/Commands/WalletCharge.php +++ b/src/app/Console/Commands/WalletCharge.php @@ -52,6 +52,11 @@ ); $wallet->chargeEntitlements(); + + if ($wallet->balance < 0) { + // Disabled for now + // \App\Jobs\WalletPayment::dispatch($wallet); + } } } } diff --git a/src/app/Http/Controllers/API/PaymentsController.php b/src/app/Http/Controllers/API/PaymentsController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/PaymentsController.php @@ -0,0 +1,221 @@ +user(); + + // TODO: Wallet selection + $wallet = $current_user->wallets()->first(); + + // Check required fields + $v = Validator::make( + $request->all(), + [ + 'amount' => 'required|int|min:1', + ] + ); + + if ($v->fails()) { + return response()->json(['status' => 'error', 'errors' => $v->errors()], 422); + } + + // Register the user in Mollie, if not yet done + // FIXME: Maybe Mollie ID should be bound to a wallet, but then + // The same customer could technicly have multiple + // Mollie IDs, then we'd need to use some "virtual" email + // address (e.g. @) instead of the user email address + $customer_id = $current_user->getSetting('mollie_id'); + $seq_type = 'oneoff'; + + if (empty($customer_id)) { + $customer = mollie()->customers()->create([ + 'name' => $current_user->name, + 'email' => $current_user->email, + ]); + + $seq_type = 'first'; + $customer_id = $customer->id; + $current_user->setSetting('mollie_id', $customer_id); + } + + $payment_request = [ + 'amount' => [ + 'currency' => 'CHF', + // a number with two decimals is required + 'value' => sprintf('%.2f', $request->amount / 100), + ], + 'customerId' => $customer_id, + 'sequenceType' => $seq_type, // 'first' / 'oneoff' / 'recurring' + 'description' => 'Kolab Now Payment', // required + 'redirectUrl' => \url('/wallet'), // required for non-recurring payments + 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), + 'locale' => 'en_US', + ]; + + // Create the payment in Mollie + $payment = mollie()->payments()->create($payment_request); + + // Store the payment reference in database + self::storePayment($payment, $wallet->id, $request->amount); + + return response()->json([ + 'status' => 'success', + 'redirectUrl' => $payment->getCheckoutUrl(), + ]); + } + + /** + * Update payment status (and balance). + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\Response The response + */ + public function webhook(Request $request) + { + $db_payment = Payment::find($request->id); + + // Mollie recommends to return "200 OK" even if the payment does not exist + if (empty($db_payment)) { + return response('Success', 200); + } + + // Get the payment details from Mollie + $payment = mollie()->payments()->get($request->id); + + if (empty($payment)) { + return response('Success', 200); + } + + if ($payment->isPaid()) { + if (!$payment->hasRefunds() && !$payment->hasChargebacks()) { + // The payment is paid and isn't refunded or charged back. + // Update the balance, if it wasn't already + if ($db_payment->status != 'paid') { + $db_payment->wallet->credit($db_payment->amount); + } + } elseif ($payment->hasRefunds()) { + // The payment has been (partially) refunded. + // The status of the payment is still "paid" + // TODO: Update balance + } elseif ($payment->hasChargebacks()) { + // The payment has been (partially) charged back. + // The status of the payment is still "paid" + // TODO: Update balance + } + } + + // This is a sanity check, just in case the payment provider api + // sent us open -> paid -> open -> paid. So, we lock the payment after it's paid. + if ($db_payment->status != 'paid') { + $db_payment->status = $payment->status; + $db_payment->save(); + } + + return response('Success', 200); + } + + /** + * Charge a wallet with a "recurring" payment. + * + * @param \App\Wallet $wallet The wallet to charge + * @param int $amount The amount of money in cents + * + * @return bool + */ + public static function directCharge(Wallet $wallet, $amount): bool + { + $customer_id = $wallet->owner->getSetting('mollie_id'); + + if (empty($customer_id)) { + return false; + } + + // Check if there's at least one valid mandate + $mandates = mollie()->mandates()->listFor($customer_id)->filter(function ($mandate) { + return $mandate->isValid(); + }); + + if (empty($mandates)) { + return false; + } + + $payment_request = [ + 'amount' => [ + 'currency' => 'CHF', + // a number with two decimals is required + 'value' => sprintf('%.2f', $amount / 100), + ], + 'customerId' => $customer_id, + 'sequenceType' => 'recurring', + 'description' => 'Kolab Now Recurring Payment', + 'webhookUrl' => self::serviceUrl('/api/webhooks/payment/mollie'), + ]; + + // Create the payment in Mollie + $payment = mollie()->payments()->create($payment_request); + + // Store the payment reference in database + self::storePayment($payment, $wallet->id, $amount); + + return true; + } + + /** + * Create self URL + * + * @param string $route Route/Path + * + * @return string Full URL + */ + protected static function serviceUrl(string $route): string + { + $url = \url($route); + + // When testing the host might be e.g. 127.0.0.1:8000. + // This will not be accepted by Mollie. Let's use our fqdn instead. + // This does not have to be working URL, we do not require Mollie + // to come back (yet). + if (preg_match('|^https?://[0-9][^/]+|', $url, $matches)) { + $url = str_replace($matches[0], \config('app.public_url'), $url); + } + + return $url; + } + + /** + * Create a payment record in DB + * + * @param object $payment Mollie payment + * @param string $wallet_id Wallet ID + * @param int $amount Amount of money in cents + */ + protected static function storePayment($payment, $wallet_id, $amount): void + { + $db_payment = new Payment(); + $db_payment->id = $payment->id; + $db_payment->description = $payment->description; + $db_payment->status = $payment->status; + $db_payment->amount = $amount; + $db_payment->wallet_id = $wallet_id; + $db_payment->save(); + } +} diff --git a/src/app/Jobs/WalletPayment.php b/src/app/Jobs/WalletPayment.php new file mode 100644 --- /dev/null +++ b/src/app/Jobs/WalletPayment.php @@ -0,0 +1,51 @@ +wallet = $wallet; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + if (!$this->wallet->balance < 0) { + PaymentsController::directCharge($this->wallet, $this->wallet->balance * -1); + } + } +} diff --git a/src/app/Observers/WalletObserver.php b/src/app/Observers/WalletObserver.php --- a/src/app/Observers/WalletObserver.php +++ b/src/app/Observers/WalletObserver.php @@ -60,5 +60,11 @@ if ($wallet->entitlements()->count() > 0) { return false; } +/* + // can't remove a wallet that has payments attached. + if ($wallet->payments()->count() > 0) { + return false; + } +*/ } } diff --git a/src/app/Payment.php b/src/app/Payment.php new file mode 100644 --- /dev/null +++ b/src/app/Payment.php @@ -0,0 +1,37 @@ + 'integer' + ]; + + /** + * The wallet to which this payment belongs. + * + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function wallet() + { + return $this->belongsTo( + '\App\Wallet', + 'wallet_id', /* local */ + 'id' /* remote */ + ); + } +} diff --git a/src/app/Wallet.php b/src/app/Wallet.php --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -93,7 +93,6 @@ return $charges; } - /** * Calculate the expected charges to this wallet. * @@ -121,11 +120,11 @@ /** * Add an amount of pecunia to this wallet's balance. * - * @param float $amount The amount of pecunia to add. + * @param int $amount The amount of pecunia to add (in cents). * - * @return Wallet + * @return Wallet Self */ - public function credit(float $amount) + public function credit(int $amount): Wallet { $this->balance += $amount; @@ -137,11 +136,11 @@ /** * Deduct an amount of pecunia from this wallet's balance. * - * @param float $amount The amount of pecunia to deduct. + * @param int $amount The amount of pecunia to deduct (in cents). * - * @return Wallet + * @return Wallet Self */ - public function debit(float $amount) + public function debit(int $amount): Wallet { $this->balance -= $amount; @@ -184,4 +183,14 @@ { return $this->belongsTo('App\User', 'user_id', 'id'); } + + /** + * Payments on this wallet. + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function payments() + { + return $this->hasMany('App\Payment'); + } } diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -22,6 +22,7 @@ "kolab/net_ldap3": "dev-master", "laravel/framework": "6.*", "laravel/tinker": "^1.0", + "mollie/laravel-mollie": "^2.9", "morrislaptop/laravel-queue-clear": "^1.2", "silviolleite/laravelpwa": "^1.0", "spatie/laravel-translatable": "^4.2", diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -53,6 +53,8 @@ 'url' => env('APP_URL', 'http://localhost'), + 'public_url' => env('APP_PUBLIC_URL', env('APP_URL', 'http://localhost')), + 'asset_url' => env('ASSET_URL', null), /* diff --git a/src/database/migrations/2020_03_16_100000_create_payments.php b/src/database/migrations/2020_03_16_100000_create_payments.php new file mode 100644 --- /dev/null +++ b/src/database/migrations/2020_03_16_100000_create_payments.php @@ -0,0 +1,40 @@ +string('id', 16)->primary(); + $table->string('wallet_id', 36); + $table->string('status', 16); + $table->integer('amount'); + $table->text('description'); + $table->timestamps(); + + $table->foreign('wallet_id')->references('id')->on('wallets')->onDelete('cascade'); + } + ); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('payments'); + } +} diff --git a/src/resources/sass/app.scss b/src/resources/sass/app.scss --- a/src/resources/sass/app.scss +++ b/src/resources/sass/app.scss @@ -160,8 +160,8 @@ .badge { position: absolute; - top: .5rem; - right: .5rem; + top: 0.5rem; + right: 0.5rem; } } 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 @@ -7,6 +7,7 @@

Current account balance is {{ $root.price(balance) }}

+ @@ -28,6 +29,14 @@ }) }, methods: { + payment() { + axios.post('/api/v4/payments', {amount: 1000}) + .then(response => { + if (response.data.redirectUrl) { + location.href = response.data.redirectUrl + } + }) + } } } diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -49,5 +49,9 @@ Route::apiResource('skus', API\SkusController::class); Route::apiResource('users', API\UsersController::class); Route::apiResource('wallets', API\WalletsController::class); + + Route::post('payments', 'API\PaymentsController@store'); } ); + +Route::post('webhooks/payment/mollie', 'API\PaymentsController@webhook'); diff --git a/src/tests/Browser/Components/Error.php b/src/tests/Browser/Components/Error.php --- a/src/tests/Browser/Components/Error.php +++ b/src/tests/Browser/Components/Error.php @@ -13,7 +13,7 @@ 400 => "Bad request", 401 => "Unauthorized", 403 => "Access denied", - 404 => "Not Found", + 404 => "Not found", 405 => "Method not allowed", 500 => "Internal server error", ]; @@ -44,8 +44,10 @@ public function assert($browser) { $browser->waitFor($this->selector()) - ->assertSeeIn('@code', $this->code) - ->assertSeeIn('@message', $this->message); + ->assertSeeIn('@code', $this->code); + + $message = $browser->text('@message'); + PHPUnit::assertSame(strtolower($message), strtolower($this->message)); } /** diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/PaymentMollie.php copy from src/tests/Browser/Pages/Wallet.php copy to src/tests/Browser/Pages/PaymentMollie.php --- a/src/tests/Browser/Pages/Wallet.php +++ b/src/tests/Browser/Pages/PaymentMollie.php @@ -4,7 +4,7 @@ use Laravel\Dusk\Page; -class Wallet extends Page +class PaymentMollie extends Page { /** * Get the URL for the page. @@ -13,7 +13,7 @@ */ public function url(): string { - return '/wallet'; + return ''; } /** @@ -25,9 +25,7 @@ */ public function assert($browser) { - $browser->assertPathIs($this->url()) - ->waitUntilMissing('@app .app-loader') - ->assertSeeIn('#wallet .card-title', 'Account balance'); + $browser->waitFor('#container'); } /** @@ -38,7 +36,11 @@ public function elements(): array { return [ - '@app' => '#app', + '@form' => '#container', + '@title' => '#container .header__info', + '@amount' => '#container .header__amount', + '@methods' => '#payment-method-list', + '@status-table' => 'table.table--select-status', ]; } } diff --git a/src/tests/Browser/Pages/Wallet.php b/src/tests/Browser/Pages/Wallet.php --- a/src/tests/Browser/Pages/Wallet.php +++ b/src/tests/Browser/Pages/Wallet.php @@ -39,6 +39,7 @@ { return [ '@app' => '#app', + '@main' => '#wallet' ]; } } diff --git a/src/tests/Browser/PaymentTest.php b/src/tests/Browser/PaymentTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Browser/PaymentTest.php @@ -0,0 +1,82 @@ +deleteTestUser('payment-test@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('payment-test@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test a payment process + * + * @group mollie + */ + public function testPayment(): void + { + $user = $this->getTestUser('payment-test@kolabnow.com', [ + 'password' => 'simple123', + ]); + + $this->browse(function (Browser $browser) { + $browser->visit(new Home()) + ->submitLogon('payment-test@kolabnow.com', 'simple123', true) + ->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->click('@main button') + ->on(new PaymentMollie()) + ->assertSeeIn('@title', 'Kolab Now Payment') + ->assertSeeIn('@amount', 'CHF 10.00'); + + // Looks like the Mollie testing mode is limited. + // We'll select credit card method and mark the payment as paid + // We can't do much more, we have to trust Mollie their page works ;) + + // For some reason I don't get the method selection form, it + // immediately jumps to the next step. Let's detect that + if ($browser->element('@methods')) { + $browser->click('@methods button.grid-button-creditcard') + ->waitFor('button.form__button'); + } + + $browser->click('@status-table input[value="paid"]') + ->click('button.form__button'); + + // Now it should redirect back to wallet page and in background + // use the webhook to update payment status (and balance). + + // Looks like in test-mode the webhook is executed before redirect + // so we can expect balance updated on the wallet page + + $browser->waitForLocation('/wallet') + ->on(new WalletPage()) + ->assertSeeIn('@main .card-text', 'Current account balance is 10,00 CHF'); + }); + } +} diff --git a/src/tests/Feature/Controller/PaymentsTest.php b/src/tests/Feature/Controller/PaymentsTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/PaymentsTest.php @@ -0,0 +1,156 @@ +getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $john->setSetting('mollie_id', null); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $john = $this->getTestUser('john@kolab.org'); + $wallet = $john->wallets()->first(); + $john->setSetting('mollie_id', null); + Payment::where('wallet_id', $wallet->id)->delete(); + Wallet::where('id', $wallet->id)->update(['balance' => 0]); + + parent::tearDown(); + } + + /** + * Test creating a payment and receiving a status via webhook) + * + * @group mollie + */ + public function testStoreAndWebhook(): void + { + // Unauth access not allowed + $response = $this->post("api/v4/payments", []); + $response->assertStatus(401); + + $user = $this->getTestUser('john@kolab.org'); + + $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']); + $this->assertSame('The amount must be at least 1.', $json['errors']['amount'][0]); + + $post = ['amount' => 1234]; + $response = $this->actingAs($user)->post("api/v4/payments", $post); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame('success', $json['status']); + $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']); + + $wallet = $user->wallets()->first(); + $payments = Payment::where('wallet_id', $wallet->id)->get(); + + $this->assertCount(1, $payments); + $payment = $payments[0]; + $this->assertSame(1234, $payment->amount); + $this->assertSame('Kolab Now Payment', $payment->description); + $this->assertSame('open', $payment->status); + $this->assertEquals(0, $wallet->balance); + + // Test the webhook + // Note: Webhook end-point does not require authentication + + $mollie_response = [ + "resource" => "payment", + "id" => $payment->id, + "status" => "paid", + // Status is not enough, paidAt is used to distinguish the state + "paidAt" => date('c'), + "mode" => "test", +/* + "createdAt" => "2018-03-20T13:13:37+00:00", + "amount" => { + "value" => "10.00", + "currency" => "EUR" + }, + "description" => "Order #12345", + "method" => null, + "metadata" => { + "order_id" => "12345" + }, + "isCancelable" => false, + "locale" => "nl_NL", + "restrictPaymentMethodsToCountry" => "NL", + "expiresAt" => "2018-03-20T13:28:37+00:00", + "details" => null, + "profileId" => "pfl_QkEhN94Ba", + "sequenceType" => "oneoff", + "redirectUrl" => "https://webshop.example.org/order/12345/", + "webhookUrl" => "https://webshop.example.org/payments/webhook/", +*/ + ]; + + // We'll trigger the webhook with payment id and use mocking for + // a request to the Mollie payments API. We cannot force Mollie + // to make the payment status change. + $responseStack = $this->mockMollie(); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $post = ['id' => $payment->id]; + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + // Verify "paid -> open -> paid" scenario, assert that balance didn't change + $mollie_response['status'] = 'open'; + unset($mollie_response['paidAt']); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + + $mollie_response['status'] = 'paid'; + $mollie_response['paidAt'] = date('c'); + $responseStack->append(new Response(200, [], json_encode($mollie_response))); + + $response = $this->post("api/webhooks/payment/mollie", $post); + $response->assertStatus(200); + + $this->assertSame('paid', $payment->fresh()->status); + $this->assertEquals(1234, $wallet->fresh()->balance); + } + + public function testDirectCharge(): void + { + $this->markTestIncomplete(); + } +} diff --git a/src/tests/MollieMocksTrait.php b/src/tests/MollieMocksTrait.php new file mode 100644 --- /dev/null +++ b/src/tests/MollieMocksTrait.php @@ -0,0 +1,51 @@ +push( + Middleware::history($this->mollieRequestHistory) + ); + + $guzzle = new Client(['handler' => $handler]); + + $this->app->forgetInstance('mollie.api.client'); + $this->app->forgetInstance('mollie.api'); + $this->app->forgetInstance('mollie'); + + $this->app->singleton('mollie.api.client', function () use ($guzzle) { + return new MollieApiClient($guzzle); + }); + + return $mockHandler; + } + + public function unmockMollie() + { + $this->app->forgetInstance('mollie.api.client'); + $this->app->forgetInstance('mollie.api'); + $this->app->forgetInstance('mollie'); + } +}