diff --git a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
--- a/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
+++ b/src/app/Http/Controllers/API/V4/Admin/WalletsController.php
@@ -45,6 +45,7 @@
$result['provider'] = $provider->name();
$result['providerLink'] = $provider->customerLink($wallet);
+ $result['notice'] = $this->getWalletNotice($wallet); // for resellers
return response()->json($result);
}
diff --git a/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php b/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php
new file mode 100644
--- /dev/null
+++ b/src/app/Http/Controllers/API/V4/Reseller/PaymentsController.php
@@ -0,0 +1,7 @@
+ 'first',
'description' => $payment['description'],
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
- 'redirectUrl' => Utils::serviceUrl('/wallet'),
+ 'redirectUrl' => self::redirectUrl(),
'locale' => 'en_US',
'method' => $payment['methodId']
];
@@ -198,7 +198,7 @@
'webhookUrl' => Utils::serviceUrl('/api/webhooks/payment/mollie'),
'locale' => 'en_US',
'method' => $payment['methodId'],
- 'redirectUrl' => Utils::serviceUrl('/wallet') // required for non-recurring payments
+ 'redirectUrl' => self::redirectUrl() // required for non-recurring payments
];
// TODO: Additional payment parameters for better fraud protection:
diff --git a/src/app/Providers/Payment/Stripe.php b/src/app/Providers/Payment/Stripe.php
--- a/src/app/Providers/Payment/Stripe.php
+++ b/src/app/Providers/Payment/Stripe.php
@@ -70,8 +70,8 @@
$request = [
'customer' => $customer_id,
- 'cancel_url' => Utils::serviceUrl('/wallet'), // required
- 'success_url' => Utils::serviceUrl('/wallet'), // required
+ 'cancel_url' => self::redirectUrl(), // required
+ 'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'mode' => 'setup',
@@ -188,8 +188,8 @@
$request = [
'customer' => $customer_id,
- 'cancel_url' => Utils::serviceUrl('/wallet'), // required
- 'success_url' => Utils::serviceUrl('/wallet'), // required
+ 'cancel_url' => self::redirectUrl(), // required
+ 'success_url' => self::redirectUrl(), // required
'payment_method_types' => ['card'], // required
'locale' => 'en',
'line_items' => [
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
@@ -387,4 +387,22 @@
return $methods;
}
+
+ /**
+ * Returns the full URL for the wallet page, used when returning from an external payment page.
+ * Depending on the request origin it will return a URL for the User or Reseller UI.
+ *
+ * @return string The redirect URL
+ */
+ public static function redirectUrl(): string
+ {
+ $url = \App\Utils::serviceUrl('/wallet');
+ $domain = preg_replace('/:[0-9]+$/', '', request()->getHttpHost());
+
+ if (strpos($domain, 'reseller') === 0) {
+ $url = preg_replace('|^(https?://)([^/]+)|', '\\1' . $domain, $url);
+ }
+
+ return $url;
+ }
}
diff --git a/src/resources/js/reseller/routes.js b/src/resources/js/reseller/routes.js
--- a/src/resources/js/reseller/routes.js
+++ b/src/resources/js/reseller/routes.js
@@ -7,6 +7,7 @@
import PageComponent from '../../vue/Page'
import StatsComponent from '../../vue/Reseller/Stats'
import UserComponent from '../../vue/Admin/User'
+import WalletComponent from '../../vue/Wallet'
const routes = [
{
@@ -59,6 +60,12 @@
component: UserComponent,
meta: { requiresAuth: true }
},
+ {
+ path: '/wallet',
+ name: 'wallet',
+ component: WalletComponent,
+ meta: { requiresAuth: true }
+ },
{
name: '404',
path: '*',
diff --git a/src/resources/vue/Reseller/Dashboard.vue b/src/resources/vue/Reseller/Dashboard.vue
--- a/src/resources/vue/Reseller/Dashboard.vue
+++ b/src/resources/vue/Reseller/Dashboard.vue
@@ -2,6 +2,10 @@
+
+ Wallet
+ {{ $root.price(balance) }}
+
Invitations
@@ -15,13 +19,33 @@
diff --git a/src/routes/api.php b/src/routes/api.php
--- a/src/routes/api.php
+++ b/src/routes/api.php
@@ -189,6 +189,16 @@
Route::apiResource('invitations', API\V4\Reseller\InvitationsController::class);
Route::post('invitations/{id}/resend', 'API\V4\Reseller\InvitationsController@resend');
Route::apiResource('packages', API\V4\Reseller\PackagesController::class);
+
+ Route::post('payments', 'API\V4\Reseller\PaymentsController@store');
+ Route::get('payments/mandate', 'API\V4\Reseller\PaymentsController@mandate');
+ Route::post('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateCreate');
+ Route::put('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateUpdate');
+ Route::delete('payments/mandate', 'API\V4\Reseller\PaymentsController@mandateDelete');
+ Route::get('payments/methods', 'API\V4\Reseller\PaymentsController@paymentMethods');
+ Route::get('payments/pending', 'API\V4\Reseller\PaymentsController@payments');
+ Route::get('payments/has-pending', 'API\V4\Reseller\PaymentsController@hasPayments');
+
Route::apiResource('skus', API\V4\Reseller\SkusController::class);
Route::apiResource('users', API\V4\Reseller\UsersController::class);
Route::post('users/{id}/reset2FA', 'API\V4\Reseller\UsersController@reset2FA');
@@ -197,6 +207,8 @@
Route::post('users/{id}/unsuspend', 'API\V4\Reseller\UsersController@unsuspend');
Route::apiResource('wallets', API\V4\Reseller\WalletsController::class);
Route::post('wallets/{id}/one-off', 'API\V4\Reseller\WalletsController@oneOff');
+ Route::get('wallets/{id}/receipts', 'API\V4\Reseller\WalletsController@receipts');
+ Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\Reseller\WalletsController@receiptDownload');
Route::get('wallets/{id}/transactions', 'API\V4\Reseller\WalletsController@transactions');
Route::apiResource('discounts', API\V4\Reseller\DiscountsController::class);
diff --git a/src/tests/Browser/Pages/Home.php b/src/tests/Browser/Pages/Home.php
--- a/src/tests/Browser/Pages/Home.php
+++ b/src/tests/Browser/Pages/Home.php
@@ -49,11 +49,11 @@
/**
* Submit logon form.
*
- * @param \Laravel\Dusk\Browser $browser The browser object
- * @param string $username User name
- * @param string $password User password
- * @param bool $wait_for_dashboard
- * @param array $config Client-site config
+ * @param \Tests\Browser $browser The browser object
+ * @param string $username User name
+ * @param string $password User password
+ * @param bool $wait_for_dashboard
+ * @param array $config Client-site config
*
* @return void
*/
@@ -64,7 +64,8 @@
$wait_for_dashboard = false,
$config = []
) {
- $browser->type('@email-input', $username)
+ $browser->clearToasts()
+ ->type('@email-input', $username)
->type('@password-input', $password);
if ($username == 'ned@kolab.org') {
diff --git a/src/tests/Browser/Reseller/LogonTest.php b/src/tests/Browser/Reseller/LogonTest.php
--- a/src/tests/Browser/Reseller/LogonTest.php
+++ b/src/tests/Browser/Reseller/LogonTest.php
@@ -28,7 +28,7 @@
$this->browse(function (Browser $browser) {
$browser->visit(new Home())
->with(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
})
->assertMissing('@second-factor-input')
->assertMissing('@forgot-password');
@@ -76,7 +76,7 @@
// Checks if we're really on Dashboard page
$browser->on(new Dashboard())
->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'dashboard', 'logout', 'lang']);
})
->assertUser('reseller@reseller.com');
@@ -107,7 +107,7 @@
// with default menu
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
});
// Success toast message
@@ -134,7 +134,7 @@
// with default menu
$browser->within(new Menu(), function ($browser) {
- $browser->assertMenuItems(['explore', 'blog', 'support', 'login']);
+ $browser->assertMenuItems(['explore', 'blog', 'support', 'login', 'lang']);
});
// Success toast message
diff --git a/src/tests/Browser/Reseller/PaymentMollieTest.php b/src/tests/Browser/Reseller/PaymentMollieTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/PaymentMollieTest.php
@@ -0,0 +1,116 @@
+getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->payments()->delete();
+ $wallet->balance = 0;
+ $wallet->save();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test the payment process
+ *
+ * @group mollie
+ */
+ public function testPayment(): void
+ {
+ $this->browse(function (Browser $browser) {
+ $user = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->payments()->delete();
+ $wallet->balance = 0;
+ $wallet->save();
+
+ $browser->visit(new Home())
+ ->submitLogon($user->email, 'reseller', 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 #creditcard')
+ ->waitFor('#payment-method-selection #paypal')
+ ->assertMissing('#payment-method-selection #banktransfer')
+ ->click('#creditcard');
+ })
+ ->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');
+ })
+ ->on(new PaymentMollie())
+ ->assertSeeIn('@title', \config('app.name') . ' Payment')
+ ->assertSeeIn('@amount', 'CHF 12.34');
+
+ $this->assertSame(1, $wallet->payments()->count());
+
+ // 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-title', 'Account balance 12,34 CHF');
+ });
+ }
+}
diff --git a/src/tests/Browser/Reseller/WalletTest.php b/src/tests/Browser/Reseller/WalletTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Browser/Reseller/WalletTest.php
@@ -0,0 +1,248 @@
+getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ $wallet->balance = 0;
+ $wallet->save();
+ $wallet->payments()->delete();
+ $wallet->transactions()->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test wallet page (unauthenticated)
+ */
+ public function testWalletUnauth(): void
+ {
+ // Test that the page requires authentication
+ $this->browse(function (Browser $browser) {
+ $browser->visit('/wallet')->on(new Home());
+ });
+ }
+
+ /**
+ * Test wallet "box" on Dashboard
+ */
+ public function testDashboard(): void
+ {
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ Wallet::where('user_id', $reseller->id)->update(['balance' => 125]);
+
+ // Positive balance
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Home())
+ ->submitLogon('reseller@kolabnow.com', 'reseller', true)
+ ->on(new Dashboard())
+ ->assertSeeIn('@links .link-wallet .name', 'Wallet')
+ ->assertSeeIn('@links .link-wallet .badge-success', '1,25 CHF');
+ });
+
+ Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]);
+
+ // Negative balance
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new Dashboard())
+ ->assertSeeIn('@links .link-wallet .name', 'Wallet')
+ ->assertSeeIn('@links .link-wallet .badge-danger', '-12,34 CHF');
+ });
+ }
+
+ /**
+ * Test wallet page
+ *
+ * @depends testDashboard
+ */
+ public function testWallet(): void
+ {
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ Wallet::where('user_id', $reseller->id)->update(['balance' => -1234]);
+
+ $this->browse(function (Browser $browser) {
+ $browser->click('@links .link-wallet')
+ ->on(new WalletPage())
+ ->assertSeeIn('#wallet .card-title', 'Account balance -12,34 CHF')
+ ->assertSeeIn('#wallet .card-title .text-danger', '-12,34 CHF')
+ ->assertSeeIn('#wallet .card-text', 'You are out of credit');
+ });
+ }
+
+ /**
+ * Test Receipts tab
+ *
+ * @depends testWallet
+ */
+ public function testReceipts(): void
+ {
+ $user = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->payments()->delete();
+
+ // Assert Receipts tab content when there's no receipts available
+ $this->browse(function (Browser $browser) {
+ $browser->visit(new WalletPage())
+ ->assertSeeIn('#wallet .card-title', 'Account balance 0,00 CHF')
+ ->assertSeeIn('#wallet .card-title .text-success', '0,00 CHF')
+ ->assertSeeIn('#wallet .card-text', 'You are in your free trial period.') // TODO
+ ->assertSeeIn('@nav #tab-receipts', 'Receipts')
+ ->with('@receipts-tab', function (Browser $browser) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertSeeIn('p', 'There are no receipts for payments')
+ ->assertDontSeeIn('p', 'Here you can download')
+ ->assertMissing('select')
+ ->assertMissing('button');
+ });
+ });
+
+ // Create some sample payments
+ $receipts = [];
+ $date = Carbon::create(intval(date('Y')) - 1, 3, 30);
+ $payment = Payment::create([
+ 'id' => 'AAA1',
+ 'status' => PaymentProvider::STATUS_PAID,
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'description' => 'Paid in March',
+ 'wallet_id' => $wallet->id,
+ 'provider' => 'stripe',
+ 'amount' => 1111,
+ 'currency_amount' => 1111,
+ 'currency' => 'CHF',
+ ]);
+ $payment->updated_at = $date;
+ $payment->save();
+ $receipts[] = $date->format('Y-m');
+
+ $date = Carbon::create(intval(date('Y')) - 1, 4, 30);
+ $payment = Payment::create([
+ 'id' => 'AAA2',
+ 'status' => PaymentProvider::STATUS_PAID,
+ 'type' => PaymentProvider::TYPE_ONEOFF,
+ 'description' => 'Paid in April',
+ 'wallet_id' => $wallet->id,
+ 'provider' => 'stripe',
+ 'amount' => 1111,
+ 'currency_amount' => 1111,
+ 'currency' => 'CHF',
+ ]);
+ $payment->updated_at = $date;
+ $payment->save();
+ $receipts[] = $date->format('Y-m');
+
+ // Assert Receipts tab with receipts available
+ $this->browse(function (Browser $browser) use ($receipts) {
+ $browser->refresh()
+ ->on(new WalletPage())
+ ->assertSeeIn('@nav #tab-receipts', 'Receipts')
+ ->with('@receipts-tab', function (Browser $browser) use ($receipts) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertDontSeeIn('p', 'There are no receipts for payments')
+ ->assertSeeIn('p', 'Here you can download')
+ ->assertSeeIn('button', 'Download')
+ ->assertElementsCount('select > option', 2)
+ ->assertSeeIn('select > option:nth-child(1)', $receipts[1])
+ ->assertSeeIn('select > option:nth-child(2)', $receipts[0]);
+
+ // Download a receipt file
+ $browser->select('select', $receipts[0])
+ ->click('button')
+ ->pause(2000);
+
+ $files = glob(__DIR__ . '/../downloads/*.pdf');
+
+ $filename = pathinfo($files[0], PATHINFO_BASENAME);
+ $this->assertTrue(strpos($filename, $receipts[0]) !== false);
+
+ $content = $browser->readDownloadedFile($filename, 0);
+ $this->assertStringStartsWith("%PDF-1.", $content);
+
+ $browser->removeDownloadedFile($filename);
+ });
+ });
+ }
+
+ /**
+ * Test History tab
+ *
+ * @depends testWallet
+ */
+ public function testHistory(): void
+ {
+ $user = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $user->wallets()->first();
+ $wallet->transactions()->delete();
+
+ // Create some sample transactions
+ $transactions = $this->createTestTransactions($wallet);
+ $transactions = array_reverse($transactions);
+ $pages = array_chunk($transactions, 10 /* page size*/);
+
+ $this->browse(function (Browser $browser) use ($pages) {
+ $browser->on(new WalletPage())
+ ->assertSeeIn('@nav #tab-history', 'History')
+ ->click('@nav #tab-history')
+ ->with('@history-tab', function (Browser $browser) use ($pages) {
+ $browser->waitUntilMissing('.app-loader')
+ ->assertElementsCount('table tbody tr', 10)
+ ->assertMissing('table td.email')
+ ->assertSeeIn('#transactions-loader button', 'Load more');
+
+ foreach ($pages[0] as $idx => $transaction) {
+ $selector = 'table tbody tr:nth-child(' . ($idx + 1) . ')';
+ $priceStyle = $transaction->type == Transaction::WALLET_AWARD ? 'text-success' : 'text-danger';
+ $browser->assertSeeIn("$selector td.description", $transaction->shortDescription())
+ ->assertMissing("$selector td.selection button")
+ ->assertVisible("$selector td.price.{$priceStyle}");
+ // TODO: Test more transaction details
+ }
+
+ // Load the next page
+ $browser->click('#transactions-loader button')
+ ->waitUntilMissing('.app-loader')
+ ->assertElementsCount('table tbody tr', 12)
+ ->assertMissing('#transactions-loader button');
+
+ $debitEntry = null;
+ foreach ($pages[1] as $idx => $transaction) {
+ $selector = 'table tbody tr:nth-child(' . ($idx + 1 + 10) . ')';
+ $priceStyle = $transaction->type == Transaction::WALLET_CREDIT ? 'text-success' : 'text-danger';
+ $browser->assertSeeIn("$selector td.description", $transaction->shortDescription());
+
+ if ($transaction->type == Transaction::WALLET_DEBIT) {
+ $debitEntry = $selector;
+ } else {
+ $browser->assertMissing("$selector td.selection button");
+ }
+ }
+ });
+ });
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
new file mode 100644
--- /dev/null
+++ b/src/tests/Feature/Controller/Reseller/PaymentsMollieTest.php
@@ -0,0 +1,257 @@
+ 'mollie']);
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)->delete();
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function tearDown(): void
+ {
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ Payment::where('wallet_id', $wallet->id)->delete();
+ Wallet::where('id', $wallet->id)->update(['balance' => 0]);
+ WalletSetting::where('wallet_id', $wallet->id)->delete();
+ Transaction::where('object_id', $wallet->id)->delete();
+
+ parent::tearDown();
+ }
+
+ /**
+ * Test creating/updating/deleting an outo-payment mandate
+ *
+ * @group mollie
+ */
+ public function testMandates(): void
+ {
+ // Unauth access not allowed
+ $response = $this->get("api/v4/payments/mandate");
+ $response->assertStatus(401);
+ $response = $this->post("api/v4/payments/mandate", []);
+ $response->assertStatus(401);
+ $response = $this->put("api/v4/payments/mandate", []);
+ $response->assertStatus(401);
+ $response = $this->delete("api/v4/payments/mandate");
+ $response->assertStatus(401);
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+ $wallet = $reseller->wallets()->first();
+ $wallet->balance = -10;
+ $wallet->save();
+
+ // Test creating a mandate (valid input)
+ $post = ['amount' => 20.10, 'balance' => 0];
+ $response = $this->actingAs($reseller)->post("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
+
+ // Assert the proper payment amount has been used
+ $payment = Payment::where('id', $json['id'])->first();
+
+ $this->assertSame(2010, $payment->amount);
+ $this->assertSame($wallet->id, $payment->wallet_id);
+ $this->assertSame(\config('app.name') . " Auto-Payment Setup", $payment->description);
+ $this->assertSame(PaymentProvider::TYPE_MANDATE, $payment->type);
+
+ // Test fetching the mandate information
+ $response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals(20.10, $json['amount']);
+ $this->assertEquals(0, $json['balance']);
+ $this->assertEquals('Credit Card', $json['method']);
+ $this->assertSame(true, $json['isPending']);
+ $this->assertSame(false, $json['isValid']);
+ $this->assertSame(false, $json['isDisabled']);
+
+ $mandate_id = $json['id'];
+
+ // We would have to invoke a browser to accept the "first payment" to make
+ // the mandate validated/completed. Instead, we'll mock the mandate object.
+ $mollie_response = [
+ 'resource' => 'mandate',
+ 'id' => $mandate_id,
+ 'status' => 'valid',
+ 'method' => 'creditcard',
+ 'details' => [
+ 'cardNumber' => '4242',
+ 'cardLabel' => 'Visa',
+ ],
+ 'customerId' => 'cst_GMfxGPt7Gj',
+ 'createdAt' => '2020-04-28T11:09:47+00:00',
+ ];
+
+ $responseStack = $this->mockMollie();
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $wallet = $reseller->wallets()->first();
+ $wallet->setSetting('mandate_disabled', 1);
+
+ $response = $this->actingAs($reseller)->get("api/v4/payments/mandate");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertEquals(20.10, $json['amount']);
+ $this->assertEquals(0, $json['balance']);
+ $this->assertEquals('Visa (**** **** **** 4242)', $json['method']);
+ $this->assertSame(false, $json['isPending']);
+ $this->assertSame(true, $json['isValid']);
+ $this->assertSame(true, $json['isDisabled']);
+
+ Bus::fake();
+ $wallet->setSetting('mandate_disabled', null);
+ $wallet->balance = 1000;
+ $wallet->save();
+
+ // Test updating a mandate (valid input)
+ $responseStack->append(new Response(200, [], json_encode($mollie_response)));
+
+ $post = ['amount' => 30.10, 'balance' => 10];
+ $response = $this->actingAs($reseller)->put("api/v4/payments/mandate", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The auto-payment has been updated.', $json['message']);
+ $this->assertSame($mandate_id, $json['id']);
+ $this->assertFalse($json['isDisabled']);
+
+ $wallet->refresh();
+
+ $this->assertEquals(30.10, $wallet->getSetting('mandate_amount'));
+ $this->assertEquals(10, $wallet->getSetting('mandate_balance'));
+
+ Bus::assertDispatchedTimes(\App\Jobs\WalletCharge::class, 0);
+
+ $this->unmockMollie();
+
+ // Delete mandate
+ $response = $this->actingAs($reseller)->delete("api/v4/payments/mandate");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertSame('The auto-payment has been removed.', $json['message']);
+ }
+
+ /**
+ * Test creating a payment
+ *
+ * @group mollie
+ */
+ public function testStore(): void
+ {
+ Bus::fake();
+
+ // Unauth access not allowed
+ $response = $this->post("api/v4/payments", []);
+ $response->assertStatus(401);
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ // Successful payment
+ $post = ['amount' => '12.34', 'currency' => 'CHF', 'methodId' => 'creditcard'];
+ $response = $this->actingAs($reseller)->post("api/v4/payments", $post);
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame('success', $json['status']);
+ $this->assertRegExp('|^https://www.mollie.com|', $json['redirectUrl']);
+ }
+
+ /**
+ * Test listing a pending payment
+ *
+ * @group mollie
+ */
+ public function testListingPayments(): void
+ {
+ Bus::fake();
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ // Empty response
+ $response = $this->actingAs($reseller)->get("api/v4/payments/pending");
+ $response->assertStatus(200);
+
+ $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($reseller)->get("api/v4/payments/has-pending");
+ $response->assertStatus(200);
+
+ $json = $response->json();
+
+ $this->assertSame(false, $json['hasPending']);
+ }
+
+ /**
+ * Test listing payment methods
+ *
+ * @group mollie
+ */
+ public function testListingPaymentMethods(): void
+ {
+ Bus::fake();
+
+ $reseller = $this->getTestUser('reseller@kolabnow.com');
+
+ $response = $this->actingAs($reseller)->get('api/v4/payments/methods?type=' . PaymentProvider::TYPE_ONEOFF);
+ $response->assertStatus(200);
+ $json = $response->json();
+
+ $this->assertCount(2, $json);
+ $this->assertSame('creditcard', $json[0]['id']);
+ $this->assertSame('paypal', $json[1]['id']);
+ }
+}
diff --git a/src/tests/Feature/Controller/Reseller/WalletsTest.php b/src/tests/Feature/Controller/Reseller/WalletsTest.php
--- a/src/tests/Feature/Controller/Reseller/WalletsTest.php
+++ b/src/tests/Feature/Controller/Reseller/WalletsTest.php
@@ -77,6 +77,7 @@
$this->assertTrue(!empty($json['provider']));
$this->assertTrue(empty($json['providerLink']));
$this->assertTrue(!empty($json['mandate']));
+ $this->assertTrue(!empty($json['notice']));
// Reseller from a different tenant
\config(['app.tenant_id' => 2]);