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;
@@ -95,6 +96,86 @@
}
/**
+ * 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.
*
* @param string $id Wallet identifier
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/vue/Wallet.vue b/src/resources/vue/Wallet.vue
--- a/src/resources/vue/Wallet.vue
+++ b/src/resources/vue/Wallet.vue
@@ -14,15 +14,44 @@
-
+
+
+
+
+ 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.
+
+
+
+
+
@@ -144,15 +173,16 @@
paymentDialogTitle: null,
paymentForm: 'init',
provider: window.config.paymentProvider,
+ receipts: [],
stripe: null,
- transactions: [],
- transactions_more: false,
- transactions_page: 1,
+ loadTransactions: false,
walletId: null,
wallet_currency: 'CHF'
}
},
mounted() {
+ $('#wallet button').focus()
+
this.balance = 0
// TODO: currencies, multi-wallets, accounts
this.$store.state.authInfo.wallets.forEach(wallet => {
@@ -162,6 +192,26 @@
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 = true
+ }
+ })
+
if (this.provider == 'stripe') {
this.stripeInit()
}
@@ -249,6 +299,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;
@@ -81,6 +84,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
*/
public function testHistory(): void
@@ -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)
->assertMissing('table td.email')
->assertSeeIn('#transactions-loader button', 'Load more');
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
@@ -29,6 +32,105 @@
}
/**
+ * 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
*/
public function testTransactions(): void