Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117753319
D1348.1775193762.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
23 KB
Referenced Files
None
Subscribers
None
D1348.1775193762.diff
View Options
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,87 @@
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 = [
+ 'year' => $year,
+ 'month' => \trans('documents.month' . intval($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 :month :year",
'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 @@
<head>
<meta charset="utf-8">
<style>
-{!! include('public/css/document.css') !!}
+{!! include(public_path('css/document.css')) !!}
</style>
</head>
<body>
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 @@
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item">
- <a class="nav-link active" id="tab-history" href="#wallet-history" role="tab" aria-controls="wallet-history" aria-selected="true">
+ <a class="nav-link active" id="tab-receipts" href="#wallet-receipts" role="tab" aria-controls="wallet-receipts" aria-selected="true">
+ Receipts
+ </a>
+ </li>
+ <li class="nav-item">
+ <a class="nav-link" id="tab-history" href="#wallet-history" role="tab" aria-controls="wallet-history" aria-selected="false">
History
</a>
</li>
</ul>
<div class="tab-content">
- <div class="tab-pane show active" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
+ <div class="tab-pane show active" id="wallet-receipts" role="tabpanel" aria-labelledby="tab-receipts">
+ <div class="card-body">
+ <div class="card-text">
+ <p v-if="receipts.length">
+ Here you can download receipts (in PDF format) for payments in specified period.
+ Select the period and press the Download button.
+ </p>
+ <div v-if="receipts.length" class="input-group">
+ <select id="receipt-id" class="form-control">
+ <option v-for="(receipt, index) in receipts" :key="index" :value="receipt">{{ receipt }}</option>
+ </select>
+ <div class="input-group-append">
+ <button type="button" class="btn btn-secondary" @click="receiptDownload">
+ <svg-icon icon="download"></svg-icon> Download
+ </button>
+ </div>
+ </div>
+ <p v-if="!receipts.length">
+ There are no receipts for payments in this account. Please, note that you can download
+ receipts after the month ends.
+ </p>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane show" id="wallet-history" role="tabpanel" aria-labelledby="tab-history">
<div class="card-body">
<div class="card-text">
<table class="table table-sm m-0">
@@ -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 = $('<div class="list-details"><ul></ul><div>').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, "March " . (intval(date('Y')) - 1)) !== 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 December $year";
+
+ $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
*/
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Fri, Apr 3, 5:22 AM (1 d, 13 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18822687
Default Alt Text
D1348.1775193762.diff (23 KB)
Attached To
Mode
D1348: Receipts UI
Attached
Detach File
Event Timeline