diff --git a/src/app/Console/Commands/WalletUntil.php b/src/app/Console/Commands/WalletUntil.php index f3072462..6e544e1d 100644 --- a/src/app/Console/Commands/WalletUntil.php +++ b/src/app/Console/Commands/WalletUntil.php @@ -1,50 +1,50 @@ argument('wallet')); if (!$wallet) { return 1; } - $lastsUntil = $wallet->balanceLastsUntil(); + $until = $wallet->balanceLastsUntil(); - $this->info("Lasts until: {$lastsUntil}"); + $this->info("Lasts until: " . ($until ? $until->toDateString() : 'unknown')); } } diff --git a/src/app/Http/Controllers/API/V4/WalletsController.php b/src/app/Http/Controllers/API/V4/WalletsController.php index 19098382..9f4188a0 100644 --- a/src/app/Http/Controllers/API/V4/WalletsController.php +++ b/src/app/Http/Controllers/API/V4/WalletsController.php @@ -1,260 +1,318 @@ errorResponse(404); } /** * Show the form for creating a new resource. * * @return \Illuminate\Http\JsonResponse */ public function create() { return $this->errorResponse(404); } /** * Store a newly created resource in storage. * * @param \Illuminate\Http\Request $request * * @return \Illuminate\Http\JsonResponse */ public function store(Request $request) { return $this->errorResponse(404); } /** - * Display the specified resource. + * Return data of the specified wallet. * - * @param string $id + * @param string $id A wallet identifier * - * @return \Illuminate\Http\JsonResponse + * @return \Illuminate\Http\JsonResponse The response */ public function show($id) { - return $this->errorResponse(404); + $wallet = Wallet::find($id); + + if (empty($wallet)) { + return $this->errorResponse(404); + } + + // Only owner (or admin) has access to the wallet + if (!Auth::guard()->user()->canRead($wallet)) { + return $this->errorResponse(403); + } + + $result = $wallet->toArray(); + + $provider = \App\Providers\PaymentProvider::factory($wallet); + + $result['provider'] = $provider->name(); + $result['notice'] = $this->getWalletNotice($wallet); + + return response()->json($result); } /** * Show the form for editing the specified resource. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function edit($id) { return $this->errorResponse(404); } /** * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function update(Request $request, $id) { return $this->errorResponse(404); } /** * Remove the specified resource from storage. * * @param int $id * * @return \Illuminate\Http\JsonResponse */ public function destroy($id) { 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. * * @param string $id Wallet identifier * * @return \Illuminate\Http\JsonResponse */ public function transactions($id) { $wallet = Wallet::find($id); // Only owner (or admin) has access to the wallet if (!Auth::guard()->user()->canRead($wallet)) { return $this->errorResponse(403); } $pageSize = 10; $page = intval(request()->input('page')) ?: 1; $hasMore = false; $isAdmin = $this instanceof Admin\WalletsController; if ($transaction = request()->input('transaction')) { // Get sub-transactions for the specified transaction ID, first // check access rights to the transaction's wallet $transaction = $wallet->transactions()->where('id', $transaction)->first(); if (!$transaction) { return $this->errorResponse(404); } $result = Transaction::where('transaction_id', $transaction->id)->get(); } else { // Get main transactions (paged) $result = $wallet->transactions() // FIXME: Do we know which (type of) transaction has sub-transactions // without the sub-query? ->selectRaw("*, (SELECT count(*) FROM transactions sub " . "WHERE sub.transaction_id = transactions.id) AS cnt") ->whereNull('transaction_id') ->latest() ->limit($pageSize + 1) ->offset($pageSize * ($page - 1)) ->get(); if (count($result) > $pageSize) { $result->pop(); $hasMore = true; } } $result = $result->map(function ($item) use ($isAdmin) { $amount = $item->amount; if (in_array($item->type, [Transaction::WALLET_PENALTY, Transaction::WALLET_DEBIT])) { $amount *= -1; } $entry = [ 'id' => $item->id, 'createdAt' => $item->created_at->format('Y-m-d H:i'), 'type' => $item->type, 'description' => $item->shortDescription(), 'amount' => $amount, 'hasDetails' => !empty($item->cnt), ]; if ($isAdmin && $item->user_email) { $entry['user'] = $item->user_email; } return $entry; }); return response()->json([ 'status' => 'success', 'list' => $result, 'count' => count($result), 'hasMore' => $hasMore, 'page' => $page, ]); } + + /** + * Returns human readable notice about the wallet state. + * + * @param \App\Wallet $wallet The wallet + */ + protected function getWalletNotice(Wallet $wallet): ?string + { + if ($wallet->balance < 0) { + return \trans('app.wallet-notice-nocredit'); + } + + if ($wallet->discount && $wallet->discount->discount == 100) { + return null; + } + + if ($wallet->owner->created_at > Carbon::now()->subDays(14)) { + return \trans('app.wallet-notice-trial'); + } + + if ($until = $wallet->balanceLastsUntil()) { + if ($until->isToday()) { + if ($wallet->owner->created_at > Carbon::now()->subDays(30)) { + return \trans('app.wallet-notice-trial-end'); + } + + return \trans('app.wallet-notice-today'); + } + + $params = [ + 'date' => $until->toDateString(), + 'days' => Carbon::now()->diffForHumans($until, Carbon::DIFF_ABSOLUTE), + ]; + + return \trans('app.wallet-notice-date', $params); + } + + return null; + } } diff --git a/src/app/Wallet.php b/src/app/Wallet.php index cc9cf2d0..cadc3650 100644 --- a/src/app/Wallet.php +++ b/src/app/Wallet.php @@ -1,367 +1,382 @@ 0, 'currency' => 'CHF' ]; protected $fillable = [ 'currency' ]; protected $nullable = [ 'description', ]; protected $casts = [ 'balance' => 'integer', ]; /** * Add a controller to this wallet. * * @param \App\User $user The user to add as a controller to this wallet. * * @return void */ public function addController(User $user) { if (!$this->controllers->contains($user)) { $this->controllers()->save($user); } } public function chargeEntitlements($apply = true) { $charges = 0; $discount = $this->getDiscountRate(); DB::beginTransaction(); // used to parent individual entitlement billings to the wallet debit. $entitlementTransactions = []; foreach ($this->entitlements()->get()->fresh() as $entitlement) { // This entitlement has been created less than or equal to 14 days ago (this is at // maximum the fourteenth 24-hour period). if ($entitlement->created_at > Carbon::now()->subDays(14)) { continue; } // This entitlement was created, or billed last, less than a month ago. if ($entitlement->updated_at > Carbon::now()->subMonthsWithoutOverflow(1)) { continue; } // created more than a month ago -- was it billed? if ($entitlement->updated_at <= Carbon::now()->subMonthsWithoutOverflow(1)) { $diff = $entitlement->updated_at->diffInMonths(Carbon::now()); $cost = (int) ($entitlement->cost * $discount * $diff); $charges += $cost; // if we're in dry-run, you know... if (!$apply) { continue; } $entitlement->updated_at = $entitlement->updated_at->copy()->addMonthsWithoutOverflow($diff); $entitlement->save(); if ($cost == 0) { continue; } $entitlementTransactions[] = $entitlement->createTransaction( \App\Transaction::ENTITLEMENT_BILLED, $cost ); } } if ($apply) { $this->debit($charges, $entitlementTransactions); } DB::commit(); return $charges; } /** * Calculate for how long the current balance will last. * - * @return \Carbon\Carbon Date + * Returns NULL for balance < 0 or discount = 100% or on a fresh account + * + * @return \Carbon\Carbon|null Date */ public function balanceLastsUntil() { - $balance = $this->balance; + if ($this->balance < 0 || $this->getDiscount() == 100) { + return null; + } // retrieve any expected charges $expectedCharge = $this->expectedCharges(); // get the costs per day for all entitlements billed against this wallet $costsPerDay = $this->costsPerDay(); + if (!$costsPerDay) { + return null; + } + // the number of days this balance, minus the expected charges, would last - $daysDelta = ($balance - $expectedCharge) / $costsPerDay; + $daysDelta = ($this->balance - $expectedCharge) / $costsPerDay; // calculate from the last entitlement billed $entitlement = $this->entitlements()->orderBy('updated_at', 'desc')->first(); - return $entitlement->updated_at->copy()->addDays($daysDelta); - } + $until = $entitlement->updated_at->copy()->addDays($daysDelta); - /** - * A helper to display human-readable amount of money using - * the wallet currency and specified locale. - * - * @param int $amount A amount of money (in cents) - * @param string $locale A locale for the output - * - * @return string String representation, e.g. "9.99 CHF" - */ - public function money(int $amount, $locale = 'de_DE') - { - $amount = round($amount / 100, 2); - - // Prefer intl extension's number formatter - if (class_exists('NumberFormatter')) { - $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); - $result = $nf->formatCurrency($amount, $this->currency); - // Replace non-breaking space - return str_replace("\xC2\xA0", " ", $result); + // Don't return dates from the past + if ($until < Carbon::now() && !$until->isToday()) { + return null; } - return sprintf('%.2f %s', $amount, $this->currency); + return $until; } /** * Controllers of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function controllers() { return $this->belongsToMany( 'App\User', // The foreign object definition 'user_accounts', // The table name 'wallet_id', // The local foreign key 'user_id' // The remote foreign key ); } /** * Retrieve the costs per day of everything charged to this wallet. * * @return float */ public function costsPerDay() { $costs = (float) 0; foreach ($this->entitlements as $entitlement) { $costs += $entitlement->costsPerDay(); } return $costs; } /** * Add an amount of pecunia to this wallet's balance. * * @param int $amount The amount of pecunia to add (in cents). * @param string $description The transaction description * * @return Wallet Self */ public function credit(int $amount, string $description = ''): Wallet { $this->balance += $amount; $this->save(); \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_CREDIT, 'amount' => $amount, 'description' => $description ] ); return $this; } /** * Deduct an amount of pecunia from this wallet's balance. * * @param int $amount The amount of pecunia to deduct (in cents). * @param array $eTIDs List of transaction IDs for the individual entitlements that make up * this debit record, if any. * @return Wallet Self */ public function debit(int $amount, array $eTIDs = []): Wallet { if ($amount == 0) { return $this; } $this->balance -= $amount; $this->save(); $transaction = \App\Transaction::create( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class, 'type' => \App\Transaction::WALLET_DEBIT, 'amount' => $amount ] ); \App\Transaction::whereIn('id', $eTIDs)->update(['transaction_id' => $transaction->id]); return $this; } /** * The discount assigned to the wallet. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function discount() { return $this->belongsTo('App\Discount', 'discount_id', 'id'); } /** * Entitlements billed to this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function entitlements() { return $this->hasMany('App\Entitlement'); } /** * Calculate the expected charges to this wallet. * * @return int */ public function expectedCharges() { return $this->chargeEntitlements(false); } /** * Return the exact, numeric version of the discount to be applied. * * Ranges from 0 - 100. * * @return int */ public function getDiscount() { return $this->discount ? $this->discount->discount : 0; } /** * The actual discount rate for use in multiplication * * Ranges from 0.00 to 1.00. */ public function getDiscountRate() { return (100 - $this->getDiscount()) / 100; } + /** + * A helper to display human-readable amount of money using + * the wallet currency and specified locale. + * + * @param int $amount A amount of money (in cents) + * @param string $locale A locale for the output + * + * @return string String representation, e.g. "9.99 CHF" + */ + public function money(int $amount, $locale = 'de_DE') + { + $amount = round($amount / 100, 2); + + // Prefer intl extension's number formatter + if (class_exists('NumberFormatter')) { + $nf = new \NumberFormatter($locale, \NumberFormatter::CURRENCY); + $result = $nf->formatCurrency($amount, $this->currency); + // Replace non-breaking space + return str_replace("\xC2\xA0", " ", $result); + } + + return sprintf('%.2f %s', $amount, $this->currency); + } + /** * The owner of the wallet -- the wallet is in his/her back pocket. * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function owner() { 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'); } /** * Remove a controller from this wallet. * * @param \App\User $user The user to remove as a controller from this wallet. * * @return void */ public function removeController(User $user) { if ($this->controllers->contains($user)) { $this->controllers()->detach($user); } } /** * Any (additional) properties of this wallet. * * @return \Illuminate\Database\Eloquent\Relations\HasMany */ public function settings() { return $this->hasMany('App\WalletSetting'); } /** * Retrieve the transactions against this wallet. * * @return \Illuminate\Database\Eloquent\Builder Query builder */ public function transactions() { return \App\Transaction::where( [ 'object_id' => $this->id, 'object_type' => \App\Wallet::class ] ); } } diff --git a/src/resources/js/app.js b/src/resources/js/app.js index fbcd3181..a208cba6 100644 --- a/src/resources/js/app.js +++ b/src/resources/js/app.js @@ -1,319 +1,319 @@ /** * First we will load all of this project's JavaScript dependencies which * includes Vue and other libraries. It is a great starting point when * building robust, powerful web applications using Vue and Laravel. */ require('./bootstrap') import AppComponent from '../vue/App' import MenuComponent from '../vue/Widgets/Menu' import store from './store' const loader = '
Loading
' const app = new Vue({ el: '#app', components: { AppComponent, MenuComponent, }, store, router: window.router, data() { return { isLoading: true, isAdmin: window.isAdmin } }, methods: { // Clear (bootstrap) form validation state clearFormValidation(form) { $(form).find('.is-invalid').removeClass('is-invalid') $(form).find('.invalid-feedback').remove() }, isController(wallet_id) { if (wallet_id && store.state.authInfo) { let i for (i = 0; i < store.state.authInfo.wallets.length; i++) { if (wallet_id == store.state.authInfo.wallets[i].id) { return true } } for (i = 0; i < store.state.authInfo.accounts.length; i++) { if (wallet_id == store.state.authInfo.accounts[i].id) { return true } } } return false }, // Set user state to "logged in" loginUser(token, dashboard) { store.commit('logoutUser') // destroy old state data store.commit('loginUser') localStorage.setItem('token', token) axios.defaults.headers.common.Authorization = 'Bearer ' + token if (dashboard !== false) { this.$router.push(store.state.afterLogin || { name: 'dashboard' }) } store.state.afterLogin = null }, // Set user state to "not logged in" logoutUser() { store.commit('logoutUser') localStorage.setItem('token', '') delete axios.defaults.headers.common.Authorization this.$router.push({ name: 'login' }) }, // Display "loading" overlay inside of the specified element addLoader(elem) { $(elem).css({position: 'relative'}).append($(loader).addClass('small')) }, // Remove loader element added in addLoader() removeLoader(elem) { $(elem).find('.app-loader').remove() }, startLoading() { this.isLoading = true // Lock the UI with the 'loading...' element let loading = $('#app > .app-loader').show() if (!loading.length) { $('#app').append($(loader)) } }, // Hide "loading" overlay stopLoading() { $('#app > .app-loader').fadeOut() this.isLoading = false }, errorPage(code, msg) { // Until https://github.com/vuejs/vue-router/issues/977 is implemented // we can't really use router to display error page as it has two side // effects: it changes the URL and adds the error page to browser history. // For now we'll be replacing current view with error page "manually". const map = { 400: "Bad request", 401: "Unauthorized", 403: "Access denied", 404: "Not found", 405: "Method not allowed", 500: "Internal server error" } if (!msg) msg = map[code] || "Unknown Error" const error_page = `
${code}
${msg}
` $('#error-page').remove() $('#app').append(error_page) }, errorHandler(error) { this.stopLoading() if (!error.response) { // TODO: probably network connection error } else if (error.response.status === 401) { this.logoutUser() } else { 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' }) + price(price, currency) { + return (price/100).toLocaleString('de-DE', { style: 'currency', currency: currency || 'CHF' }) }, priceLabel(cost, units = 1, discount) { let index = '' if (units < 0) { units = 1 } if (discount) { cost = Math.floor(cost * ((100 - discount) / 100)) index = '\u00B9' } return this.price(cost * units) + '/month' + index }, domainStatusClass(domain) { if (domain.isDeleted) { return 'text-muted' } if (domain.isSuspended) { return 'text-warning' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'text-danger' } return 'text-success' }, domainStatusText(domain) { if (domain.isDeleted) { return 'Deleted' } if (domain.isSuspended) { return 'Suspended' } if (!domain.isVerified || !domain.isLdapReady || !domain.isConfirmed) { return 'Not Ready' } return 'Active' }, userStatusClass(user) { if (user.isDeleted) { return 'text-muted' } if (user.isSuspended) { return 'text-warning' } if (!user.isImapReady || !user.isLdapReady) { return 'text-danger' } return 'text-success' }, userStatusText(user) { if (user.isDeleted) { return 'Deleted' } if (user.isSuspended) { return 'Suspended' } if (!user.isImapReady || !user.isLdapReady) { return 'Not Ready' } return 'Active' } } }) // Add a axios request interceptor window.axios.interceptors.request.use( config => { // This is the only way I found to change configuration options // on a running application. We need this for browser testing. config.headers['X-Test-Payment-Provider'] = window.config.paymentProvider return config }, error => { // Do something with request error return Promise.reject(error) } ) // Add a axios response interceptor for general/validation error handler window.axios.interceptors.response.use( response => { // Do nothing return response }, error => { let error_msg let status = error.response ? error.response.status : 200 if (error.response && status == 422) { error_msg = "Form validation error" const modal = $('div.modal.show') $(modal.length ? modal : 'form').each((i, form) => { form = $(form) $.each(error.response.data.errors || {}, (idx, msg) => { const input_name = (form.data('validation-prefix') || form.find('form').first().data('validation-prefix') || '') + idx let input = form.find('#' + input_name) if (!input.length) { input = form.find('[name="' + input_name + '"]'); } if (input.length) { // Create an error message\ // API responses can use a string, array or object let msg_text = '' if ($.type(msg) !== 'string') { $.each(msg, (index, str) => { msg_text += str + ' ' }) } else { msg_text = msg } let feedback = $('
').text(msg_text) if (input.is('.list-input')) { // List input widget input.children(':not(:first-child)').each((index, element) => { if (msg[index]) { $(element).find('input').addClass('is-invalid') } }) input.addClass('is-invalid').next('.invalid-feedback').remove() input.after(feedback) } else { // Standard form element input.addClass('is-invalid') input.parent().find('.invalid-feedback').remove() input.parent().append(feedback) } } }) form.find('.is-invalid:not(.listinput-widget)').first().focus() }) } else if (error.response && error.response.data) { error_msg = error.response.data.message } else { error_msg = error.request ? error.request.statusText : error.message } app.$toast.error(error_msg || "Server Error") // Pass the error as-is return Promise.reject(error) } ) diff --git a/src/resources/lang/en/app.php b/src/resources/lang/en/app.php index f68f231e..71dfa809 100644 --- a/src/resources/lang/en/app.php +++ b/src/resources/lang/en/app.php @@ -1,47 +1,53 @@ 'The auto-payment has been removed.', 'mandate-update-success' => 'The auto-payment has been updated.', 'planbutton' => 'Choose :plan', 'process-user-new' => 'Registering a user...', 'process-user-ldap-ready' => 'Creating a user...', 'process-user-imap-ready' => 'Creating a mailbox...', 'process-domain-new' => 'Registering a custom domain...', 'process-domain-ldap-ready' => 'Creating a custom domain...', 'process-domain-verified' => 'Verifying a custom domain...', 'process-domain-confirmed' => 'Verifying an ownership of a custom domain...', 'process-success' => 'Setup process finished successfully.', 'process-error-user-ldap-ready' => 'Failed to create a user.', 'process-error-user-imap-ready' => 'Failed to verify that a mailbox exists.', 'process-error-domain-ldap-ready' => 'Failed to create a domain.', 'process-error-domain-verified' => 'Failed to verify a domain.', 'process-error-domain-confirmed' => 'Failed to verify an ownership of a domain.', 'domain-verify-success' => 'Domain verified successfully.', 'domain-verify-error' => 'Domain ownership verification failed.', 'user-update-success' => 'User data updated successfully.', 'user-create-success' => 'User created successfully.', 'user-delete-success' => 'User deleted successfully.', 'user-suspend-success' => 'User suspended successfully.', 'user-unsuspend-success' => 'User unsuspended successfully.', 'search-foundxdomains' => ':x domains have been found.', 'search-foundxusers' => ':x user accounts have been found.', 'wallet-award-success' => 'The bonus has been added to the wallet successfully.', 'wallet-penalty-success' => 'The penalty has been added to the wallet successfully.', 'wallet-update-success' => 'User wallet updated successfully.', + + 'wallet-notice-date' => 'With your current subscriptions your account balance will last until about :date (:days).', + 'wallet-notice-nocredit' => 'You are out of credit, top up your balance now.', + 'wallet-notice-today' => 'You will run out of credit today, top up your balance now.', + 'wallet-notice-trial' => 'You are in your free trial period.', + 'wallet-notice-trial-end' => 'Your free trial is about to end, top up to continue.', ]; diff --git a/src/resources/vue/Wallet.vue b/src/resources/vue/Wallet.vue index f2d4cb68..6cad58c7 100644 --- a/src/resources/vue/Wallet.vue +++ b/src/resources/vue/Wallet.vue @@ -1,341 +1,339 @@ diff --git a/src/tests/Browser/WalletTest.php b/src/tests/Browser/WalletTest.php index e048cc7a..e86fb370 100644 --- a/src/tests/Browser/WalletTest.php +++ b/src/tests/Browser/WalletTest.php @@ -1,252 +1,256 @@ deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => -1234]); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); $john = $this->getTestUser('john@kolab.org'); Wallet::where('user_id', $john->id)->update(['balance' => 0]); 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 { // Test that the page requires authentication $this->browse(function (Browser $browser) { $browser->visit(new Home()) ->submitLogon('john@kolab.org', 'simple123', true) ->on(new Dashboard()) ->assertSeeIn('@links .link-wallet .name', 'Wallet') ->assertSeeIn('@links .link-wallet .badge', '-12,34 CHF'); }); } /** * Test wallet page * * @depends testDashboard */ public function testWallet(): void { $this->browse(function (Browser $browser) { $browser->click('@links .link-wallet') ->on(new WalletPage()) - ->assertSeeIn('#wallet .card-title', 'Account balance') - ->assertSeeIn('#wallet .card-text', 'Current account balance is -12,34 CHF'); + ->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 */ 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('#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.') ->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 { $user = $this->getTestUser('wallets-controller@kolabnow.com', ['password' => 'simple123']); // 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); }); $package_kolab = \App\Package::where('title', 'kolab')->first(); $user->assignPackage($package_kolab); $wallet = $user->wallets()->first(); // 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, $wallet) { $browser->on(new Dashboard()) ->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->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"); } } // Load sub-transactions $browser->click("$debitEntry td.selection button") ->waitUntilMissing('.app-loader') ->assertElementsCount("$debitEntry td.description ul li", 2) ->assertMissing("$debitEntry td.selection button"); }); }); } } diff --git a/src/tests/Feature/Controller/WalletsTest.php b/src/tests/Feature/Controller/WalletsTest.php index 4bc32866..b8ee06f5 100644 --- a/src/tests/Feature/Controller/WalletsTest.php +++ b/src/tests/Feature/Controller/WalletsTest.php @@ -1,254 +1,333 @@ deleteTestUser('wallets-controller@kolabnow.com'); } /** * {@inheritDoc} */ public function tearDown(): void { $this->deleteTestUser('wallets-controller@kolabnow.com'); parent::tearDown(); } + + /** + * Test for getWalletNotice() method + */ + public function testGetWalletNotice(): void + { + $user = $this->getTestUser('wallets-controller@kolabnow.com'); + $package = \App\Package::where('title', 'kolab')->first(); + $user->assignPackage($package); + $wallet = $user->wallets()->first(); + + $controller = new WalletsController(); + $method = new \ReflectionMethod($controller, 'getWalletNotice'); + $method->setAccessible(true); + + // User/entitlements created today, balance=0 + $notice = $method->invoke($controller, $wallet); + + $this->assertSame('You are in your free trial period.', $notice); + + $wallet->owner->created_at = Carbon::now()->subDays(15); + $wallet->owner->save(); + + $notice = $method->invoke($controller, $wallet); + + $this->assertSame('Your free trial is about to end, top up to continue.', $notice); + + // User/entitlements created today, balance=-10 CHF + $wallet->balance = -1000; + $notice = $method->invoke($controller, $wallet); + + $this->assertSame('You are out of credit, top up your balance now.', $notice); + + // User/entitlements created today, balance=-9,99 CHF (monthly cost) + $wallet->balance = 999; + $notice = $method->invoke($controller, $wallet); + + $this->assertTrue(strpos($notice, '(1 month)') !== false); + + // Old entitlements, 100% discount + $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); + $discount = \App\Discount::where('discount', 100)->first(); + $wallet->discount()->associate($discount); + + $notice = $method->invoke($controller, $wallet->refresh()); + + $this->assertSame(null, $notice); + } + /** * 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 a wallet (GET /api/v4/wallets/:id) + */ + public function testShow(): void + { + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $wallet = $john->wallets()->first(); + + // Accessing a wallet of someone else + $response = $this->actingAs($jack)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(403); + + // Accessing non-existing wallet + $response = $this->actingAs($jack)->get("api/v4/wallets/aaa"); + $response->assertStatus(404); + + // Wallet owner + $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame($wallet->id, $json['id']); + $this->assertSame('CHF', $json['currency']); + $this->assertSame($wallet->balance, $json['balance']); + $this->assertTrue(empty($json['description'])); + $this->assertTrue(!empty($json['notice'])); + } + /** * Test fetching wallet transactions */ public function testTransactions(): void { $package_kolab = \App\Package::where('title', 'kolab')->first(); $user = $this->getTestUser('wallets-controller@kolabnow.com'); $user->assignPackage($package_kolab); $john = $this->getTestUser('john@klab.org'); $wallet = $user->wallets()->first(); // Unauth access not allowed $response = $this->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(401); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(403); // Expect empty list $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $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']); // Create some sample transactions $transactions = $this->createTestTransactions($wallet); $transactions = array_reverse($transactions); $pages = array_chunk($transactions, 10 /* page size*/); // Get the first page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(10, $json['count']); $this->assertSame(true, $json['hasMore']); $this->assertCount(10, $json['list']); foreach ($pages[0] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertFalse($json['list'][$idx]['hasDetails']); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); } $search = null; // Get the second page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=2"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(2, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); foreach ($pages[1] as $idx => $transaction) { $this->assertSame($transaction->id, $json['list'][$idx]['id']); $this->assertSame($transaction->type, $json['list'][$idx]['type']); $this->assertSame($transaction->shortDescription(), $json['list'][$idx]['description']); $this->assertSame( $transaction->type == Transaction::WALLET_DEBIT, $json['list'][$idx]['hasDetails'] ); $this->assertFalse(array_key_exists('user', $json['list'][$idx])); if ($transaction->type == Transaction::WALLET_DEBIT) { $search = $transaction->id; } } // Get a non-existing page $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?page=3"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(3, $json['page']); $this->assertSame(0, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(0, $json['list']); // Sub-transaction searching $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction=123"); $response->assertStatus(404); $response = $this->actingAs($user)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(200); $json = $response->json(); $this->assertCount(5, $json); $this->assertSame('success', $json['status']); $this->assertSame(1, $json['page']); $this->assertSame(2, $json['count']); $this->assertSame(false, $json['hasMore']); $this->assertCount(2, $json['list']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][0]['type']); $this->assertSame(Transaction::ENTITLEMENT_BILLED, $json['list'][1]['type']); // Test that John gets 404 if he tries to access // someone else's transaction ID on his wallet's endpoint $wallet = $john->wallets()->first(); $response = $this->actingAs($john)->get("api/v4/wallets/{$wallet->id}/transactions?transaction={$search}"); $response->assertStatus(404); } } diff --git a/src/tests/Feature/WalletTest.php b/src/tests/Feature/WalletTest.php index e90234c0..c0161ee8 100644 --- a/src/tests/Feature/WalletTest.php +++ b/src/tests/Feature/WalletTest.php @@ -1,216 +1,256 @@ users as $user) { $this->deleteTestUser($user); } } public function tearDown(): void { foreach ($this->users as $user) { $this->deleteTestUser($user); } parent::tearDown(); } + /** + * Test for Wallet::balanceLastsUntil() + */ public function testBalanceLastsUntil(): void { + // Monthly cost of all entitlements: 999 + // 28 days: 35.68 per day + // 31 days: 32.22 per day + $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); - $mailbox = Sku::where('title', 'mailbox')->first(); - $user->assignPackage($package); - $wallet = $user->wallets()->first(); + // User/entitlements created today, balance=0 + $until = $wallet->balanceLastsUntil(); + + $this->assertSame(Carbon::now()->toDateString(), $until->toDateString()); + + // User/entitlements created today, balance=-10 CHF + $wallet->balance = -1000; + $until = $wallet->balanceLastsUntil(); + + $this->assertSame(null, $until); + + // User/entitlements created today, balance=-9,99 CHF (monthly cost) + $wallet->balance = 999; $until = $wallet->balanceLastsUntil(); - // TODO: Test this for typical cases - // TODO: Test this for a user with no entitlements - // TODO: Test this for a user with 100% discount - $this->markTestIncomplete(); + $daysInLastMonth = \App\Utils::daysInLastMonth(); + + $this->assertSame( + Carbon::now()->addDays($daysInLastMonth)->toDateString(), + $until->toDateString() + ); + + // Old entitlements, 100% discount + $this->backdateEntitlements($wallet->entitlements, Carbon::now()->subDays(40)); + $discount = \App\Discount::where('discount', 100)->first(); + $wallet->discount()->associate($discount); + + $until = $wallet->refresh()->balanceLastsUntil(); + + $this->assertSame(null, $until); + + // User with no entitlements + $wallet->discount()->dissociate($discount); + $wallet->entitlements()->delete(); + + $until = $wallet->refresh()->balanceLastsUntil(); + + $this->assertSame(null, $until); } + /** + * Test for Wallet::costsPerDay() + */ public function testCostsPerDay(): void { // 999 // 28 days: 35.68 // 31 days: 32.22 $user = $this->getTestUser('jane@kolabnow.com'); $package = Package::where('title', 'kolab')->first(); $mailbox = Sku::where('title', 'mailbox')->first(); $user->assignPackage($package); $wallet = $user->wallets()->first(); $costsPerDay = $wallet->costsPerDay(); $this->assertTrue($costsPerDay < 35.68); $this->assertTrue($costsPerDay > 32.22); } /** * Verify a wallet is created, when a user is created. */ public function testCreateUserCreatesWallet(): void { $user = $this->getTestUser('UserWallet1@UserWallet.com'); $this->assertCount(1, $user->wallets); } /** * Verify a user can haz more wallets. */ public function testAddWallet(): void { $user = $this->getTestUser('UserWallet2@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $this->assertCount(2, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertEquals(0, $wallet->balance); } ); } /** * Verify we can not delete a user wallet that holds balance. */ public function testDeleteWalletWithCredit(): void { $user = $this->getTestUser('UserWallet3@UserWallet.com'); $user->wallets()->each( function ($wallet) { $wallet->credit(100)->save(); } ); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can not delete a wallet that is the last wallet. */ public function testDeleteLastWallet(): void { $user = $this->getTestUser('UserWallet4@UserWallet.com'); $this->assertCount(1, $user->wallets); $user->wallets()->each( function ($wallet) { $this->assertFalse($wallet->delete()); } ); } /** * Verify we can remove a wallet that is an additional wallet. */ public function testDeleteAddtWallet(): void { $user = $this->getTestUser('UserWallet5@UserWallet.com'); $user->wallets()->save( new Wallet(['currency' => 'USD']) ); $user->wallets()->each( function ($wallet) { if ($wallet->currency == 'USD') { $this->assertNotFalse($wallet->delete()); } } ); } /** * Verify a wallet can be assigned a controller. */ public function testAddWalletController(): void { $userA = $this->getTestUser('WalletControllerA@WalletController.com'); $userB = $this->getTestUser('WalletControllerB@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $this->assertCount(1, $userB->accounts); $aWallet = $userA->wallets()->first(); $bAccount = $userB->accounts()->first(); $this->assertTrue($bAccount->id === $aWallet->id); } /** * Verify controllers can also be removed from wallets. */ public function testRemoveWalletController(): void { $userA = $this->getTestUser('WalletController2A@WalletController.com'); $userB = $this->getTestUser('WalletController2B@WalletController.com'); $userA->wallets()->each( function ($wallet) use ($userB) { $wallet->addController($userB); } ); $userB->refresh(); $userB->accounts()->each( function ($wallet) use ($userB) { $wallet->removeController($userB); } ); $this->assertCount(0, $userB->accounts); } }