diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -5,6 +5,7 @@ use App\Transaction; use App\Wallet; use App\Http\Controllers\Controller; +use App\Providers\PaymentProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -94,6 +95,86 @@ return $this->errorResponse(404); } + /** + * Download a receipt in pdf format. + * + * @param string $id Wallet identifier + * @param string $receipt Receipt identifier (YYYY-MM) + * + * @return \Illuminate\Http\Response|void + */ + public function receiptDownload($id, $receipt) + { + $wallet = Wallet::find($id); + + // Only owner (or admin) has access to the wallet + if (!Auth::guard()->user()->canRead($wallet)) { + return abort(403); + } + + list ($year, $month) = explode('-', $receipt); + + if (empty($year) || empty($month) || $year < 2000 || $month < 1 || $month > 12) { + return abort(404); + } + + if ($receipt >= date('Y-m')) { + return abort(404); + } + + $params = [ + 'id' => sprintf('%04d-%02d', $year, $month), + 'site' => \config('app.name') + ]; + + $filename = \trans('documents.receipt-filename', $params); + + $receipt = new \App\Documents\Receipt($wallet, (int) $year, (int) $month); + + $content = $receipt->pdfOutput(); + + return response($content) + ->withHeaders([ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + 'Content-Length' => strlen($content), + ]); + } + + /** + * Fetch wallet receipts list. + * + * @param string $id Wallet identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function receipts($id) + { + $wallet = Wallet::find($id); + + // Only owner (or admin) has access to the wallet + if (!Auth::guard()->user()->canRead($wallet)) { + return $this->errorResponse(403); + } + + $result = $wallet->payments() + ->selectRaw('distinct date_format(updated_at, "%Y-%m") as ident') + ->where('status', PaymentProvider::STATUS_PAID) + ->where('amount', '>', 0) + ->orderBy('ident', 'desc') + ->get() + ->whereNotIn('ident', [date('Y-m')]) // exclude current month + ->pluck('ident'); + + return response()->json([ + 'status' => 'success', + 'list' => $result, + 'count' => count($result), + 'hasMore' => false, + 'page' => 1, + ]); + } + /** * Fetch wallet transactions. * 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 @@ -122,6 +122,29 @@ this.errorPage(error.response.status, error.response.statusText) } }, + downloadFile(url) { + // TODO: This might not be a best way for big files as the content + // will be stored (temporarily) in browser memory + // TODO: This method does not show the download progress in the browser + // but it could be implemented in the UI, axios has 'progress' property + axios.get(url, { responseType: 'blob' }) + .then (response => { + const link = document.createElement('a') + const contentDisposition = response.headers['content-disposition'] + let filename = 'unknown' + + if (contentDisposition) { + const match = contentDisposition.match(/filename="(.+)"/); + if (match.length === 2) { + filename = match[1]; + } + } + + link.href = window.URL.createObjectURL(response.data) + link.download = filename + link.click() + }) + }, price(price) { return (price/100).toLocaleString('de-DE', { style: 'currency', currency: 'CHF' }) }, diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -11,6 +11,7 @@ import { faCheck, faCheckCircle, + faDownload, faGlobe, faExclamationCircle, faInfoCircle, @@ -33,6 +34,7 @@ faCheckCircle, faCheckSquare, faCreditCard, + faDownload, faExclamationCircle, faGlobe, faInfoCircle, diff --git a/src/resources/lang/en/documents.php b/src/resources/lang/en/documents.php --- a/src/resources/lang/en/documents.php +++ b/src/resources/lang/en/documents.php @@ -29,6 +29,7 @@ 'month11' => "November", 'month12' => "December", + 'receipt-filename' => ":site Receipt for :id", 'receipt-title' => "Receipt for :month :year", 'receipt-item-desc' => ":site Services", diff --git a/src/resources/views/documents/receipt.blade.php b/src/resources/views/documents/receipt.blade.php --- a/src/resources/views/documents/receipt.blade.php +++ b/src/resources/views/documents/receipt.blade.php @@ -3,7 +3,7 @@ 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 @@ -14,13 +14,42 @@
-
+
+
+
+

+ Here you can download receipts (in PDF format) for payments in specified period. + Select the period and press the Download button. +

+
+ +
+ +
+
+

+ There are no receipts for payments in this account. Please, note that you can download + receipts after the month ends. +

+
+
+
+
@@ -173,6 +202,7 @@ paymentDialogTitle: null, paymentForm: 'init', provider: window.config.paymentProvider, + receipts: [], stripe: null, transactions: [], transactions_more: false, @@ -181,6 +211,8 @@ } }, mounted() { + $('#wallet button').focus() + this.balance = 0 // TODO: currencies, multi-wallets, accounts this.$store.state.authInfo.wallets.forEach(wallet => { @@ -188,7 +220,27 @@ this.provider = wallet.provider }) - this.loadTransactions() + this.walletId = this.$store.state.authInfo.wallets[0].id + + const receiptsTab = $('#wallet-receipts') + + this.$root.addLoader(receiptsTab) + axios.get('/api/v4/wallets/' + this.walletId + '/receipts') + .then(response => { + this.$root.removeLoader(receiptsTab) + this.receipts = response.data.list + }) + .catch(error => { + this.$root.removeLoader(receiptsTab) + }) + + $(this.$el).find('ul.nav-tabs a').on('click', e => { + e.preventDefault() + $(e.target).tab('show') + if ($(e.target).is('#tab-history')) { + this.loadTransactions() + } + }) if (this.provider == 'stripe') { this.stripeInit() @@ -197,7 +249,6 @@ methods: { loadTransactions(more) { let loader = $('#wallet-history') - let walletId = this.$store.state.authInfo.wallets[0].id let param = '' if (more) { @@ -206,7 +257,7 @@ } this.$root.addLoader(loader) - axios.get('/api/v4/wallets/' + walletId + '/transactions' + param) + axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + param) .then(response => { this.$root.removeLoader(loader) // Note: In Vue we can't just use .concat() @@ -221,13 +272,12 @@ }) }, loadTransaction(id) { - let walletId = this.$store.state.authInfo.wallets[0].id let record = $('#log' + id) let cell = record.find('td.description') let details = $('
    ').appendTo(cell) this.$root.addLoader(cell) - axios.get('/api/v4/wallets/' + walletId + '/transactions' + '?transaction=' + id) + axios.get('/api/v4/wallets/' + this.walletId + '/transactions' + '?transaction=' + id) .then(response => { this.$root.removeLoader(cell) record.find('button').remove() @@ -322,6 +372,10 @@ this.paymentDialogTitle = title || 'Add auto-payment' setTimeout(() => { this.dialog.find('#mandate_amount').focus()}, 10) }, + receiptDownload() { + const receipt = $('#receipt-id').val() + this.$root.downloadFile('/api/v4/wallets/' + this.walletId + '/receipts/' + receipt) + }, stripeInit() { let script = $('#stripe-script') diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -69,6 +69,8 @@ Route::apiResource('wallets', API\V4\WalletsController::class); Route::get('wallets/{id}/transactions', 'API\V4\WalletsController@transactions'); + Route::get('wallets/{id}/receipts', 'API\V4\WalletsController@receipts'); + Route::get('wallets/{id}/receipts/{receipt}', 'API\V4\WalletsController@receiptDownload'); Route::post('payments', 'API\V4\PaymentsController@store'); Route::get('payments/mandate', 'API\V4\PaymentsController@mandate'); diff --git a/src/tests/Browser.php b/src/tests/Browser.php --- a/src/tests/Browser.php +++ b/src/tests/Browser.php @@ -161,13 +161,13 @@ /** * Returns content of a downloaded file */ - public function readDownloadedFile($filename) + public function readDownloadedFile($filename, $sleep = 5) { $filename = __DIR__ . "/Browser/downloads/$filename"; // Give the browser a chance to finish download - if (!file_exists($filename)) { - sleep(2); + if (!file_exists($filename) && $sleep) { + sleep($sleep); } Assert::assertFileExists($filename); 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 @@ -43,6 +43,7 @@ '@payment-dialog' => '#payment-dialog', '@nav' => 'ul.nav-tabs', '@history-tab' => '#wallet-history', + '@receipts-tab' => '#wallet-receipts', ]; } } diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -2,8 +2,11 @@ namespace Tests\Browser; +use App\Payment; +use App\Providers\PaymentProvider; use App\Transaction; use App\Wallet; +use Carbon\Carbon; use Tests\Browser; use Tests\Browser\Pages\Dashboard; use Tests\Browser\Pages\Home; @@ -80,6 +83,100 @@ }); } + /** + * Test Receipts tab + */ + public function testReceipts(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + + // Log out John and log in the test user + $this->browse(function (Browser $browser) { + $browser->visit('/logout') + ->waitForLocation('/login') + ->on(new Home()) + ->submitLogon('wallets-controller@kolabnow.com', 'simple123', true); + }); + + // Assert Receipts tab content when there's no receipts available + $this->browse(function (Browser $browser) { + $browser->on(new Dashboard()) + ->click('@links .link-wallet') + ->on(new WalletPage()) + ->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, + ]); + $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, + ]); + $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.3\n", $content); + + $browser->removeDownloadedFile($filename); + }); + }); + } + /** * Test History tab */ @@ -109,8 +206,10 @@ ->click('@links .link-wallet') ->on(new WalletPage()) ->assertSeeIn('@nav #tab-history', 'History') + ->click('@nav #tab-history') ->with('@history-tab', function (Browser $browser) use ($pages, $wallet) { - $browser->assertElementsCount('table tbody tr', 10) + $browser->waitUntilMissing('.app-loader') + ->assertElementsCount('table tbody tr', 10) ->assertSeeIn('#transactions-loader button', 'Load more'); foreach ($pages[0] as $idx => $transaction) { diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -3,7 +3,10 @@ namespace Tests\Feature\Controller; use App\Http\Controllers\API\V4\WalletsController; +use App\Payment; +use App\Providers\PaymentProvider; use App\Transaction; +use Carbon\Carbon; use Tests\TestCase; class WalletsTest extends TestCase @@ -28,6 +31,105 @@ parent::tearDown(); } + /** + * Test fetching pdf receipt + */ + public function testReceiptDownload(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $john = $this->getTestUser('john@klab.org'); + $wallet = $user->wallets()->first(); + + // Unauth access not allowed + $response = $this->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); + $response->assertStatus(401); + $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts/2020-05"); + $response->assertStatus(403); + + // Invalid receipt id (current month) + $receiptId = date('Y-m'); + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); + $response->assertStatus(404); + + // Invalid receipt id + $receiptId = '1000-03'; + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); + $response->assertStatus(404); + + // Valid receipt id + $year = intval(date('Y')) - 1; + $receiptId = "$year-12"; + $filename = \config('app.name') . " Receipt for $year-12"; + + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts/{$receiptId}"); + + $response->assertStatus(200); + $response->assertHeader('content-type', 'application/pdf'); + $response->assertHeader('content-disposition', 'attachment; filename="' . $filename . '"'); + $response->assertHeader('content-length'); + + $length = $response->headers->get('content-length'); + $content = $response->content(); + $this->assertStringStartsWith("%PDF-1.3\n", $content); + $this->assertEquals(strlen($content), $length); + } + + /** + * Test fetching list of receipts + */ + public function testReceipts(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $john = $this->getTestUser('john@klab.org'); + $wallet = $user->wallets()->first(); + $wallet->payments()->delete(); + + // Unauth access not allowed + $response = $this->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(401); + $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(403); + + // Empty list expected + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(5, $json); + $this->assertSame('success', $json['status']); + $this->assertSame([], $json['list']); + $this->assertSame(1, $json['page']); + $this->assertSame(0, $json['count']); + $this->assertSame(false, $json['hasMore']); + + // Insert a payment to the database + $date = Carbon::create(intval(date('Y')) - 1, 4, 30); + $payment = Payment::create([ + 'id' => 'AAA1', + 'status' => PaymentProvider::STATUS_PAID, + 'type' => PaymentProvider::TYPE_ONEOFF, + 'description' => 'Paid in April', + 'wallet_id' => $wallet->id, + 'provider' => 'stripe', + 'amount' => 1111, + ]); + $payment->updated_at = $date; + $payment->save(); + + $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/receipts"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertCount(5, $json); + $this->assertSame('success', $json['status']); + $this->assertSame([$date->format('Y-m')], $json['list']); + $this->assertSame(1, $json['page']); + $this->assertSame(1, $json['count']); + $this->assertSame(false, $json['hasMore']); + } + /** * Test fetching wallet transactions */