diff --git a/app/Http/Controllers/BillController.php b/app/Http/Controllers/BillController.php new file mode 100644 index 0000000000..bc743f4b7b --- /dev/null +++ b/app/Http/Controllers/BillController.php @@ -0,0 +1,240 @@ +with('periods', $periods)->with('subTitle', 'Create new'); + } + + /** + * @param Bill $bill + * + * @return $this + */ + public function delete(Bill $bill) + { + return view('bills.delete')->with('bill', $bill)->with('subTitle', 'Delete "' . e($bill->name) . '"'); + } + + /** + * @param Bill $bill + * + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Bill $bill) + { + $bill->delete(); + Session::flash('success', 'The bill was deleted.'); + + return Redirect::route('bills.index'); + + } + + /** + * @param Bill $bill + * + * @return $this + */ + public function edit(Bill $bill) + { + $periods = \Config::get('firefly.periods_to_text'); + + return View::make('bills.edit')->with('periods', $periods)->with('bill', $bill)->with('subTitle', 'Edit "' . e($bill->name) . '"'); + } + + /** + * @param BillRepositoryInterface $repository + * + * @return \Illuminate\View\View + */ + public function index(BillRepositoryInterface $repository) + { + $bills = Auth::user()->bills()->get(); + $bills->each( + function (Bill $bill) use ($repository) { + $bill->nextExpectedMatch = $repository->nextExpectedMatch($bill); + $last = $bill->transactionjournals()->orderBy('date', 'DESC')->first(); + $bill->lastFoundMatch = null; + if ($last) { + $bill->lastFoundMatch = $last->date; + } + } + ); + + return View::make('bills.index', compact('bills')); + } + + /** + * @param Bill $bill + * + * @return mixed + */ + public function rescan(Bill $bill, BillRepositoryInterface $repository) + { + if (intval($bill->active) == 0) { + Session::flash('warning', 'Inactive bills cannot be scanned.'); + + return Redirect::intended('/'); + } + + $set = \DB::table('transactions')->where('amount', '>', 0)->where('amount', '>=', $bill->amount_min)->where('amount', '<=', $bill->amount_max)->get(['transaction_journal_id']); + $ids = []; + + /** @var Transaction $entry */ + foreach ($set as $entry) { + $ids[] = intval($entry->transaction_journal_id); + } + if (count($ids) > 0) { + $journals = Auth::user()->transactionjournals()->whereIn('id',$ids)->get(); + /** @var TransactionJournal $journal */ + foreach ($journals as $journal) { + $repository->scan($bill, $journal); + } + } + + Session::flash('success', 'Rescanned everything.'); + + return Redirect::to(URL::previous()); + } + + /** + * @param Bill $bill + * + * @return mixed + */ + public function show(Bill $bill, BillRepositoryInterface $repository) + { + $journals = $bill->transactionjournals()->withRelevantData()->orderBy('date', 'DESC')->get(); + $bill->nextExpectedMatch = $repository->nextExpectedMatch($bill); + $hideBill = true; + + + return View::make('bills.show', compact('journals', 'hideBill', 'bill'))->with('subTitle', e($bill->name)); + } + + /** + * @return $this + */ + public function store(BillFormRequest $request, BillRepositoryInterface $repository) + { + + var_dump($request->all()); + + $billData = [ + 'name' => $request->get('name'), + 'match' => $request->get('match'), + 'amount_min' => floatval($request->get('amount_min')), + 'amount_currency_id' => floatval($request->get('amount_currency_id')), + 'amount_max' => floatval($request->get('amount_max')), + 'date' => new Carbon($request->get('date')), + 'user' => Auth::user()->id, + 'repeat_freq' => $request->get('repeat_freq'), + 'skip' => intval($request->get('skip')), + 'automatch' => intval($request->get('automatch')) === 1, + 'active' => intval($request->get('active')) === 1, + ]; + + $bill = $repository->store($billData); + Session::flash('success', 'Bill "' . e($bill->name) . '" stored.'); + + return Redirect::route('bills.index'); + + } + + /** + * @param Bill $bill + * + * @return $this + */ + public function update(Bill $bill, BillFormRequest $request, BillRepositoryInterface $repository) + { + $billData = [ + 'name' => $request->get('name'), + 'match' => $request->get('match'), + 'amount_min' => floatval($request->get('amount_min')), + 'amount_currency_id' => floatval($request->get('amount_currency_id')), + 'amount_max' => floatval($request->get('amount_max')), + 'date' => new Carbon($request->get('date')), + 'user' => Auth::user()->id, + 'repeat_freq' => $request->get('repeat_freq'), + 'skip' => intval($request->get('skip')), + 'automatch' => intval($request->get('automatch')) === 1, + 'active' => intval($request->get('active')) === 1, + ]; + + $bill = $repository->update($bill, $billData); + + Session::flash('success', 'Bill "' . e($bill->name) . '" updated.'); + + return Redirect::route('bills.index'); + + + // $data = Input::except('_token'); + // $data['active'] = intval(Input::get('active')); + // $data['automatch'] = intval(Input::get('automatch')); + // $data['user_id'] = Auth::user()->id; + // + // // always validate: + // $messages = $this->_repository->validate($data); + // + // // flash messages: + // Session::flash('warnings', $messages['warnings']); + // Session::flash('successes', $messages['successes']); + // Session::flash('errors', $messages['errors']); + // if ($messages['errors']->count() > 0) { + // Session::flash('error', 'Could not update bill: ' . $messages['errors']->first()); + // + // return Redirect::route('bills.edit', $bill->id)->withInput(); + // } + // + // // return to update screen: + // if ($data['post_submit_action'] == 'validate_only') { + // return Redirect::route('bills.edit', $bill->id)->withInput(); + // } + // + // // update + // $this->_repository->update($bill, $data); + // Session::flash('success', 'Bill "' . e($data['name']) . '" updated.'); + // + // // go back to list + // if ($data['post_submit_action'] == 'update') { + // return Redirect::route('bills.index'); + // } + // + // // go back to update screen. + // return Redirect::route('bills.edit', $bill->id)->withInput(['post_submit_action' => 'return_to_edit']); + + } + +} diff --git a/app/Http/Controllers/CategoryController.php b/app/Http/Controllers/CategoryController.php index 2eb257341e..2ef107071c 100644 --- a/app/Http/Controllers/CategoryController.php +++ b/app/Http/Controllers/CategoryController.php @@ -1,6 +1,7 @@ startOfMonth()); + $end = Session::get('end', Carbon::now()->startOfMonth()); + $list = Auth::user() + ->transactionjournals() + ->leftJoin('category_transaction_journal', 'category_transaction_journal.transaction_journal_id', '=', 'transaction_journals.id') + ->whereNull('category_transaction_journal.id') + ->before($end) + ->after($start) + ->orderBy('transaction_journals.date') + ->get(['transaction_journals.*']); + $subTitle = 'Transactions without a category in ' . $start->format('F Y'); + + return View::make('categories.noCategory', compact('list', 'subTitle')); + } + /** * @param Category $category * diff --git a/app/Http/Controllers/GoogleChartController.php b/app/Http/Controllers/GoogleChartController.php index 8eafd0d496..8b2f389799 100644 --- a/app/Http/Controllers/GoogleChartController.php +++ b/app/Http/Controllers/GoogleChartController.php @@ -12,6 +12,7 @@ use FireflyIII\Models\Bill; use FireflyIII\Models\Budget; use FireflyIII\Models\LimitRepetition; use FireflyIII\Models\TransactionJournal; +use FireflyIII\Models\Transaction; use FireflyIII\Models\Category; use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use Grumpydictator\Gchart\GChart; @@ -21,6 +22,7 @@ use Preferences; use Response; use Session; use Steam; +use Navigation; /** * Class GoogleChartController @@ -73,6 +75,50 @@ class GoogleChartController extends Controller } + /** + * @param Bill $bill + * + * @return \Illuminate\Http\JsonResponse + */ + public function billOverview(Bill $bill, GChart $chart) + { + + $chart->addColumn('Date', 'date'); + $chart->addColumn('Max amount', 'number'); + $chart->addColumn('Min amount', 'number'); + $chart->addColumn('Current entry', 'number'); + + // get first transaction or today for start: + $first = $bill->transactionjournals()->orderBy('date', 'ASC')->first(); + if ($first) { + $start = $first->date; + } else { + $start = new Carbon; + } + $end = new Carbon; + while ($start <= $end) { + $result = $bill->transactionjournals()->before($end)->after($start)->first(); + if ($result) { + /** @var Transaction $tr */ + foreach($result->transactions()->get() as $tr) { + if(floatval($tr->amount) > 0) { + $amount = floatval($tr->amount); + } + } + } else { + $amount = 0; + } + unset($result); + $chart->addRow(clone $start, $bill->amount_max, $bill->amount_min, $amount); + $start = Navigation::addPeriod($start, $bill->repeat_freq, 0); + } + + $chart->generate(); + + return Response::json($chart->getData()); + + } + /** * @param Account $account diff --git a/app/Http/Controllers/PiggyBankController.php b/app/Http/Controllers/PiggyBankController.php index 556bf8f294..0ecb765ec5 100644 --- a/app/Http/Controllers/PiggyBankController.php +++ b/app/Http/Controllers/PiggyBankController.php @@ -67,4 +67,6 @@ class PiggyBankController extends Controller { return view('piggy-banks.index', compact('piggyBanks', 'accounts')); } + + } diff --git a/app/Http/Requests/BillFormRequest.php b/app/Http/Requests/BillFormRequest.php new file mode 100644 index 0000000000..2c9452cccb --- /dev/null +++ b/app/Http/Requests/BillFormRequest.php @@ -0,0 +1,55 @@ + 0) { + $nameRule .= ','.intval(Input::get('id')); + } + + $rules = [ + 'name' => $nameRule, + 'match' => 'required|between:1,255', + 'amount_min' => 'required|numeric|min:0.01', + 'amount_max' => 'required|numeric|min:0.01', + 'amount_currency_id' => 'required|exists:transaction_currencies,id', + 'date' => 'required|date', + 'repeat_freq' => 'required|in:weekly,monthly,quarterly,half-year,yearly', + 'skip' => 'required|between:0,31', + 'automatch' => 'in:1', + 'active' => 'in:1', + ]; + + return $rules; + } +} \ No newline at end of file diff --git a/app/Http/breadcrumbs.php b/app/Http/breadcrumbs.php index b202f82636..1f6c932318 100644 --- a/app/Http/breadcrumbs.php +++ b/app/Http/breadcrumbs.php @@ -4,6 +4,7 @@ use DaveJamesMiller\Breadcrumbs\Generator; use FireflyIII\Exceptions\FireflyException; use FireflyIII\Models\Account; use FireflyIII\Models\Budget; +use FireflyIII\Models\Bill; use FireflyIII\Models\Category; use FireflyIII\Models\LimitRepetition; use FireflyIII\Models\PiggyBank; diff --git a/app/Http/routes.php b/app/Http/routes.php index 9301cc0ec8..f2d544d234 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -95,12 +95,16 @@ Route::group( * Bills Controller */ Route::get('/bills', ['uses' => 'BillController@index', 'as' => 'bills.index']); - //Route::get('/bills/rescan/{bill}', ['uses' => 'BillController@rescan', 'as' => 'bills.rescan']); # rescan for matching. + Route::get('/bills/rescan/{bill}', ['uses' => 'BillController@rescan', 'as' => 'bills.rescan']); # rescan for matching. Route::get('/bills/create', ['uses' => 'BillController@create', 'as' => 'bills.create']); - //Route::get('/bills/edit/{bill}', ['uses' => 'BillController@edit', 'as' => 'bills.edit']); - // Route::get('/bills/delete/{bill}', ['uses' => 'BillController@delete', 'as' => 'bills.delete']); + Route::get('/bills/edit/{bill}', ['uses' => 'BillController@edit', 'as' => 'bills.edit']); + Route::get('/bills/delete/{bill}', ['uses' => 'BillController@delete', 'as' => 'bills.delete']); Route::get('/bills/show/{bill}', ['uses' => 'BillController@show', 'as' => 'bills.show']); + Route::post('/bills/store', ['uses' => 'BillController@store', 'as' => 'bills.store']); + Route::post('/bills/update/{bill}', ['uses' => 'BillController@update', 'as' => 'bills.update']); + Route::post('/bills/destroy/{bill}', ['uses' => 'BillController@destroy', 'as' => 'bills.destroy']); + /** * Budget Controller */ @@ -155,7 +159,7 @@ Route::group( Route::get('/chart/reports/income-expenses/{year}', ['uses' => 'GoogleChartController@yearInExp']); Route::get('/chart/reports/income-expenses-sum/{year}', ['uses' => 'GoogleChartController@yearInExpSum']); - //Route::get('/chart/bills/{bill}', ['uses' => 'GoogleChartController@billOverview']); + Route::get('/chart/bills/{bill}', ['uses' => 'GoogleChartController@billOverview']); // JSON controller Route::get('/json/expense-accounts', ['uses' => 'JsonController@expenseAccounts', 'as' => 'json.expense-accounts']); diff --git a/app/Models/Bill.php b/app/Models/Bill.php index 63c0381554..7cdcc2e67c 100644 --- a/app/Models/Bill.php +++ b/app/Models/Bill.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Model; class Bill extends Model { + protected $fillable = ['name', 'match', 'amount_min','user_id', 'amount_max', 'date', 'repeat_freq', 'skip', 'automatch', 'active',]; + /** * @return array */ diff --git a/app/Providers/FireflyServiceProvider.php b/app/Providers/FireflyServiceProvider.php index 49c7c2907a..6d9a785905 100644 --- a/app/Providers/FireflyServiceProvider.php +++ b/app/Providers/FireflyServiceProvider.php @@ -60,6 +60,7 @@ class FireflyServiceProvider extends ServiceProvider $this->app->bind('FireflyIII\Repositories\Budget\BudgetRepositoryInterface', 'FireflyIII\Repositories\Budget\BudgetRepository'); $this->app->bind('FireflyIII\Repositories\Category\CategoryRepositoryInterface', 'FireflyIII\Repositories\Category\CategoryRepository'); $this->app->bind('FireflyIII\Repositories\Journal\JournalRepositoryInterface', 'FireflyIII\Repositories\Journal\JournalRepository'); + $this->app->bind('FireflyIII\Repositories\Bill\BillRepositoryInterface', 'FireflyIII\Repositories\Bill\BillRepository'); $this->app->bind('FireflyIII\Helpers\Report\ReportHelperInterface', 'FireflyIII\Helpers\Report\ReportHelper'); $this->app->bind('FireflyIII\Helpers\Report\ReportQueryInterface', 'FireflyIII\Helpers\Report\ReportQuery'); diff --git a/app/Repositories/Bill/BillRepository.php b/app/Repositories/Bill/BillRepository.php new file mode 100644 index 0000000000..3a5a2abf67 --- /dev/null +++ b/app/Repositories/Bill/BillRepository.php @@ -0,0 +1,198 @@ +active == 0) { + return $finalDate; + } + + /* + * $today is the start of the next period, to make sure FF3 won't miss anything + * when the current period has a transaction journal. + */ + $today = Navigation::addPeriod(new Carbon, $bill->repeat_freq, 0); + + $skip = $bill->skip + 1; + $start = Navigation::startOfPeriod(new Carbon, $bill->repeat_freq); + /* + * go back exactly one month/week/etc because FF3 does not care about 'next' + * bills if they're too far into the past. + */ + + $counter = 0; + while ($start <= $today) { + if (($counter % $skip) == 0) { + // do something. + $end = Navigation::endOfPeriod(clone $start, $bill->repeat_freq); + $journalCount = $bill->transactionjournals()->before($end)->after($start)->count(); + if ($journalCount == 0) { + $finalDate = clone $start; + break; + } + } + + // add period for next round! + $start = Navigation::addPeriod($start, $bill->repeat_freq, 0); + $counter++; + } + + return $finalDate; + } + + /** + * @param Bill $bill + * @param TransactionJournal $journal + * + * @return bool + */ + public function scan(Bill $bill, TransactionJournal $journal) + { + /* + * Match words. + */ + $wordMatch = false; + $matches = explode(',', $bill->match); + $description = strtolower($journal->description); + Log::debug('Now scanning ' . $description); + + /* + * Attach expense account to description for more narrow matching. + */ + if (count($journal->transactions) < 2) { + $transactions = $journal->transactions()->get(); + } else { + $transactions = $journal->transactions; + } + /** @var Transaction $transaction */ + foreach ($transactions as $transaction) { + /** @var Account $account */ + $account = $transaction->account()->first(); + /** @var AccountType $type */ + $type = $account->accountType()->first(); + if ($type->type == 'Expense account' || $type->type == 'Beneficiary account') { + $description .= ' ' . strtolower($account->name); + } + } + Log::debug('Final description: ' . $description); + Log::debug('Matches searched: ' . join(':', $matches)); + + $count = 0; + foreach ($matches as $word) { + if (!(strpos($description, strtolower($word)) === false)) { + $count++; + } + } + if ($count >= count($matches)) { + $wordMatch = true; + Log::debug('word match is true'); + } else { + Log::debug('Count: ' . $count.', count(matches): ' . count($matches)); + } + + + /* + * Match amount. + */ + + $amountMatch = false; + if (count($transactions) > 1) { + + $amount = max(floatval($transactions[0]->amount), floatval($transactions[1]->amount)); + $min = floatval($bill->amount_min); + $max = floatval($bill->amount_max); + if ($amount >= $min && $amount <= $max) { + $amountMatch = true; + Log::debug('Amount match is true!'); + } + } + + + /* + * If both, update! + */ + if ($wordMatch && $amountMatch) { + Log::debug('TOTAL match is true!'); + $journal->bill()->associate($bill); + $journal->save(); + } + } + + /** + * @param array $data + * + * @return Bill + */ + public function store(array $data) + { + + + $bill = Bill::create( + [ + 'name' => $data['name'], + 'match' => $data['match'], + 'amount_min' => $data['amount_min'], + 'user_id' => $data['user'], + 'amount_max' => $data['amount_max'], + 'date' => $data['date'], + 'repeat_freq' => $data['repeat_freq'], + 'skip' => $data['skip'], + 'automatch' => $data['automatch'], + 'active' => $data['active'], + + ] + ); + + return $bill; + } + + /** + * @param Bill $bill + * @param array $data + * + * @return Bill|static + */ + public function update(Bill $bill, array $data) + { + + + $bill->name = $data['name']; + $bill->match = $data['match']; + $bill->amount_min = $data['amount_min']; + $bill->amount_max = $data['amount_max']; + $bill->date = $data['date']; + $bill->repeat_freq = $data['repeat_freq']; + $bill->skip = $data['skip']; + $bill->automatch = $data['automatch']; + $bill->active = $data['active']; + $bill->save(); + + return $bill; + } +} diff --git a/app/Repositories/Bill/BillRepositoryInterface.php b/app/Repositories/Bill/BillRepositoryInterface.php new file mode 100644 index 0000000000..ead38877b1 --- /dev/null +++ b/app/Repositories/Bill/BillRepositoryInterface.php @@ -0,0 +1,51 @@ +label($name, $options); + $options = $this->expandOptionArray($name, $label, $options); + $classes = $this->getHolderClasses($name); + $value = $this->fillFieldValue($name, $value); + $options['step'] = '1'; + $html = \View::make('form.integer', compact('classes', 'name', 'label', 'value', 'options'))->render(); + + return $html; + + } + + + /** + * @param $name + * @param null $value + * @param array $options + * + * @return string + */ + public function tags($name, $value = null, array $options = []) + { + $label = $this->label($name, $options); + $options = $this->expandOptionArray($name, $label, $options); + $classes = $this->getHolderClasses($name); + $value = $this->fillFieldValue($name, $value); + $options['data-role'] = 'tagsinput'; + $html = \View::make('form.tags', compact('classes', 'name', 'label', 'value', 'options'))->render(); + + return $html; + } + /** * @param $name * @param null $value diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 6c1bd66329..4bdec6aa1a 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -15,31 +15,93 @@ class Navigation /** - * @param Carbon $date - * @param $repeatFrequency + * @param Carbon $theDate + * @param $repeatFreq + * @param $skip * - * @return string + * @return \Carbon\Carbon * @throws FireflyException */ - public function periodShow(Carbon $date, $repeatFrequency) + public function addPeriod(Carbon $theDate, $repeatFreq, $skip) { - $formatMap = [ - 'daily' => 'j F Y', - 'week' => '\W\e\e\k W, Y', - 'weekly' => '\W\e\e\k W, Y', - 'quarter' => 'F Y', - 'month' => 'F Y', - 'monthly' => 'F Y', - 'year' => 'Y', - 'yearly' => 'Y', + $date = clone $theDate; + $add = ($skip + 1); + $functionMap = [ + 'daily' => 'addDays', + 'weekly' => 'addWeeks', + 'week' => 'addWeeks', + 'month' => 'addMonths', + 'monthly' => 'addMonths', + 'quarter' => 'addMonths', + 'quarterly' => 'addMonths', + 'half-year' => 'addMonths', + 'year' => 'addYears', + 'yearly' => 'addYears', ]; - if (isset($formatMap[$repeatFrequency])) { - return $date->format($formatMap[$repeatFrequency]); + $modifierMap = [ + 'quarter' => 3, + 'quarterly' => 3, + 'half-year' => 6, + ]; + if (!isset($functionMap[$repeatFreq])) { + throw new FireflyException('Cannot do addPeriod for $repeat_freq "' . $repeatFreq . '"'); } - throw new FireflyException('No date formats for frequency "' . $repeatFrequency . '"!'); + if (isset($modifierMap[$repeatFreq])) { + $add = $add * $modifierMap[$repeatFreq]; + } + $function = $functionMap[$repeatFreq]; + $date->$function($add); + + return $date; } + /** + * @param Carbon $theCurrentEnd + * @param $repeatFreq + * + * @return Carbon + * @throws FireflyException + */ + public function endOfPeriod(Carbon $theCurrentEnd, $repeatFreq) + { + $currentEnd = clone $theCurrentEnd; + + $functionMap = [ + 'daily' => 'addDay', + 'week' => 'addWeek', + 'weekly' => 'addWeek', + 'month' => 'addMonth', + 'monthly' => 'addMonth', + 'quarter' => 'addMonths', + 'quarterly' => 'addMonths', + 'half-year' => 'addMonths', + 'year' => 'addYear', + 'yearly' => 'addYear', + ]; + $modifierMap = [ + 'quarter' => 3, + 'quarterly' => 3, + 'half-year' => 6, + ]; + + $subDay = ['week', 'weekly', 'month', 'monthly', 'quarter', 'quarterly', 'half-year', 'year', 'yearly']; + + if (!isset($functionMap[$repeatFreq])) { + throw new FireflyException('Cannot do endOfPeriod for $repeat_freq ' . $repeatFreq); + } + $function = $functionMap[$repeatFreq]; + if (isset($modifierMap[$repeatFreq])) { + $currentEnd->$function($modifierMap[$repeatFreq]); + } else { + $currentEnd->$function(); + } + if (in_array($repeatFreq, $subDay)) { + $currentEnd->subDay(); + } + + return $currentEnd; + } /** * @param $range @@ -154,6 +216,72 @@ class Navigation throw new FireflyException('No _periodName() for range "' . $range . '"'); } + /** + * @param Carbon $date + * @param $repeatFrequency + * + * @return string + * @throws FireflyException + */ + public function periodShow(Carbon $date, $repeatFrequency) + { + $formatMap = [ + 'daily' => 'j F Y', + 'week' => '\W\e\e\k W, Y', + 'weekly' => '\W\e\e\k W, Y', + 'quarter' => 'F Y', + 'month' => 'F Y', + 'monthly' => 'F Y', + 'year' => 'Y', + 'yearly' => 'Y', + + ]; + if (isset($formatMap[$repeatFrequency])) { + return $date->format($formatMap[$repeatFrequency]); + } + throw new FireflyException('No date formats for frequency "' . $repeatFrequency . '"!'); + } + + /** + * @param Carbon $theDate + * @param $repeatFreq + * + * @return Carbon + * @throws FireflyException + */ + public function startOfPeriod(Carbon $theDate, $repeatFreq) + { + $date = clone $theDate; + + $functionMap = [ + 'daily' => 'startOfDay', + 'week' => 'startOfWeek', + 'weekly' => 'startOfWeek', + 'month' => 'startOfMonth', + 'monthly' => 'startOfMonth', + 'quarter' => 'firstOfQuarter', + 'quartly' => 'firstOfQuarter', + 'year' => 'startOfYear', + 'yearly' => 'startOfYear', + ]; + if (isset($functionMap[$repeatFreq])) { + $function = $functionMap[$repeatFreq]; + $date->$function(); + + return $date; + } + if ($repeatFreq == 'half-year') { + $month = intval($date->format('m')); + $date->startOfYear(); + if ($month >= 7) { + $date->addMonths(6); + } + + return $date; + } + throw new FireflyException('Cannot do startOfPeriod for $repeat_freq ' . $repeatFreq); + } + /** * @param $range * @param Carbon $start @@ -224,47 +352,5 @@ class Navigation throw new FireflyException('updateStartDate cannot handle $range ' . $range); } - /** - * @param Carbon $theDate - * @param $repeatFreq - * @param $skip - * - * @return \Carbon\Carbon - * @throws FireflyException - */ - public function addPeriod(Carbon $theDate, $repeatFreq, $skip) - { - $date = clone $theDate; - $add = ($skip + 1); - - $functionMap = [ - 'daily' => 'addDays', - 'weekly' => 'addWeeks', - 'week' => 'addWeeks', - 'month' => 'addMonths', - 'monthly' => 'addMonths', - 'quarter' => 'addMonths', - 'quarterly' => 'addMonths', - 'half-year' => 'addMonths', - 'year' => 'addYears', - 'yearly' => 'addYears', - ]; - $modifierMap = [ - 'quarter' => 3, - 'quarterly' => 3, - 'half-year' => 6, - ]; - if (!isset($functionMap[$repeatFreq])) { - throw new FireflyException('Cannot do addPeriod for $repeat_freq "' . $repeatFreq . '"'); - } - if (isset($modifierMap[$repeatFreq])) { - $add = $add * $modifierMap[$repeatFreq]; - } - $function = $functionMap[$repeatFreq]; - $date->$function($add); - - return $date; - } - } \ No newline at end of file diff --git a/database/seeds/TestDataSeeder.php b/database/seeds/TestDataSeeder.php index b48e34dade..35bd3d8d9f 100644 --- a/database/seeds/TestDataSeeder.php +++ b/database/seeds/TestDataSeeder.php @@ -392,7 +392,7 @@ class TestDataSeeder extends Seeder $user = User::whereEmail('thegrumpydictator@gmail.com')->first(); // bill Bill::create( - ['user_id' => $user->id, 'name' => 'Rent', 'match' => 'rent,landlord', 'amount_min' => 700, 'amount_max' => 900, 'date' => $this->som, + ['user_id' => $user->id, 'name' => 'Rent', 'match' => 'rent,land,lord', 'amount_min' => 700, 'amount_max' => 900, 'date' => $this->som, 'active' => 1, 'automatch' => 1, 'repeat_freq' => 'monthly', 'skip' => 0,] ); diff --git a/public/css/bootstrap-tagsinput.css b/public/css/bootstrap-tagsinput.css new file mode 100644 index 0000000000..8686f20a6b --- /dev/null +++ b/public/css/bootstrap-tagsinput.css @@ -0,0 +1,47 @@ +.bootstrap-tagsinput { + background-color: #fff; + border: 1px solid #ccc; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); + display: inline-block; + padding: 4px 6px; + margin-bottom: 10px; + color: #555; + vertical-align: middle; + border-radius: 4px; + max-width: 100%; + width:100%; + line-height: 22px; + cursor: text; +} +.bootstrap-tagsinput input { + border: none; + box-shadow: none; + outline: none; + background-color: transparent; + padding: 0; + margin: 0; + width: auto !important; + max-width: inherit; +} +.bootstrap-tagsinput input:focus { + border: none; + box-shadow: none; +} +.bootstrap-tagsinput .tag { + margin-right: 2px; + color: white; +} +.bootstrap-tagsinput .tag [data-role="remove"] { + margin-left: 8px; + cursor: pointer; +} +.bootstrap-tagsinput .tag [data-role="remove"]:after { + content: "x"; + padding: 0px 2px; +} +.bootstrap-tagsinput .tag [data-role="remove"]:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); +} +.bootstrap-tagsinput .tag [data-role="remove"]:hover:active { + box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); +} diff --git a/public/js/bills.js b/public/js/bills.js new file mode 100644 index 0000000000..0bddb7f3ef --- /dev/null +++ b/public/js/bills.js @@ -0,0 +1,7 @@ +$(document).ready(function () { + + if (typeof(googleComboChart) === 'function' && typeof(billID) !== 'undefined') { + googleComboChart('chart/bills/' + billID, 'bill-overview'); + } + } +); \ No newline at end of file diff --git a/public/js/bootstrap-tagsinput.min.js b/public/js/bootstrap-tagsinput.min.js new file mode 100755 index 0000000000..16be0abb93 --- /dev/null +++ b/public/js/bootstrap-tagsinput.min.js @@ -0,0 +1,7 @@ +/* + * bootstrap-tagsinput v0.4.2 by Tim Schlechter + * + */ + +!function(a){"use strict";function b(b,c){this.itemsArray=[],this.$element=a(b),this.$element.hide(),this.isSelect="SELECT"===b.tagName,this.multiple=this.isSelect&&b.hasAttribute("multiple"),this.objectItems=c&&c.itemValue,this.placeholderText=b.hasAttribute("placeholder")?this.$element.attr("placeholder"):"",this.inputSize=Math.max(1,this.placeholderText.length),this.$container=a('
'),this.$input=a('').appendTo(this.$container),this.$element.after(this.$container);var d=(this.inputSize<3?3:this.inputSize)+"em";this.$input.get(0).style.cssText="width: "+d+" !important;",this.build(c)}function c(a,b){if("function"!=typeof a[b]){var c=a[b];a[b]=function(a){return a[c]}}}function d(a,b){if("function"!=typeof a[b]){var c=a[b];a[b]=function(){return c}}}function e(a){return a?i.text(a).html():""}function f(a){var b=0;if(document.selection){a.focus();var c=document.selection.createRange();c.moveStart("character",-a.value.length),b=c.text.length}else(a.selectionStart||"0"==a.selectionStart)&&(b=a.selectionStart);return b}function g(b,c){var d=!1;return a.each(c,function(a,c){if("number"==typeof c&&b.which===c)return d=!0,!1;if(b.which===c.which){var e=!c.hasOwnProperty("altKey")||b.altKey===c.altKey,f=!c.hasOwnProperty("shiftKey")||b.shiftKey===c.shiftKey,g=!c.hasOwnProperty("ctrlKey")||b.ctrlKey===c.ctrlKey;if(e&&f&&g)return d=!0,!1}}),d}var h={tagClass:function(){return"label label-info"},itemValue:function(a){return a?a.toString():a},itemText:function(a){return this.itemValue(a)},freeInput:!0,addOnBlur:!0,maxTags:void 0,maxChars:void 0,confirmKeys:[13,44],onTagExists:function(a,b){b.hide().fadeIn()},trimValue:!1,allowDuplicates:!1};b.prototype={constructor:b,add:function(b,c){var d=this;if(!(d.options.maxTags&&d.itemsArray.length>=d.options.maxTags||b!==!1&&!b)){if("string"==typeof b&&d.options.trimValue&&(b=a.trim(b)),"object"==typeof b&&!d.objectItems)throw"Can't add objects when itemValue option is not set";if(!b.toString().match(/^\s*$/)){if(d.isSelect&&!d.multiple&&d.itemsArray.length>0&&d.remove(d.itemsArray[0]),"string"==typeof b&&"INPUT"===this.$element[0].tagName){var f=b.split(",");if(f.length>1){for(var g=0;gd.options.maxInputLength)){var l=a.Event("beforeItemAdd",{item:b,cancel:!1});if(d.$element.trigger(l),!l.cancel){d.itemsArray.push(b);var m=a(''+e(i)+'');if(m.data("item",b),d.findInputWrapper().before(m),m.after(" "),d.isSelect&&!a('option[value="'+encodeURIComponent(h)+'"]',d.$element)[0]){var n=a("");n.data("item",b),n.attr("value",h),d.$element.append(n)}c||d.pushVal(),(d.options.maxTags===d.itemsArray.length||d.items().toString().length===d.options.maxInputLength)&&d.$container.addClass("bootstrap-tagsinput-max"),d.$element.trigger(a.Event("itemAdded",{item:b}))}}}else if(d.options.onTagExists){var o=a(".tag",d.$container).filter(function(){return a(this).data("item")===k});d.options.onTagExists(b,o)}}}},remove:function(b,c){var d=this;if(d.objectItems&&(b="object"==typeof b?a.grep(d.itemsArray,function(a){return d.options.itemValue(a)==d.options.itemValue(b)}):a.grep(d.itemsArray,function(a){return d.options.itemValue(a)==b}),b=b[b.length-1]),b){var e=a.Event("beforeItemRemove",{item:b,cancel:!1});if(d.$element.trigger(e),e.cancel)return;a(".tag",d.$container).filter(function(){return a(this).data("item")===b}).remove(),a("option",d.$element).filter(function(){return a(this).data("item")===b}).remove(),-1!==a.inArray(b,d.itemsArray)&&d.itemsArray.splice(a.inArray(b,d.itemsArray),1)}c||d.pushVal(),d.options.maxTags>d.itemsArray.length&&d.$container.removeClass("bootstrap-tagsinput-max"),d.$element.trigger(a.Event("itemRemoved",{item:b}))},removeAll:function(){var b=this;for(a(".tag",b.$container).remove(),a("option",b.$element).remove();b.itemsArray.length>0;)b.itemsArray.pop();b.pushVal()},refresh:function(){var b=this;a(".tag",b.$container).each(function(){var c=a(this),d=c.data("item"),f=b.options.itemValue(d),g=b.options.itemText(d),h=b.options.tagClass(d);if(c.attr("class",null),c.addClass("tag "+e(h)),c.contents().filter(function(){return 3==this.nodeType})[0].nodeValue=e(g),b.isSelect){var i=a("option",b.$element).filter(function(){return a(this).data("item")===d});i.attr("value",f)}})},items:function(){return this.itemsArray},pushVal:function(){var b=this,c=a.map(b.items(),function(a){return b.options.itemValue(a).toString()});b.$element.val(c,!0).trigger("change")},build:function(b){var e=this;if(e.options=a.extend({},h,b),e.objectItems&&(e.options.freeInput=!1),c(e.options,"itemValue"),c(e.options,"itemText"),d(e.options,"tagClass"),e.options.typeahead){var i=e.options.typeahead||{};d(i,"source"),e.$input.typeahead(a.extend({},i,{source:function(b,c){function d(a){for(var b=[],d=0;d$1")}}))}if(e.options.typeaheadjs){var j=e.options.typeaheadjs||{};e.$input.typeahead(null,j).on("typeahead:selected",a.proxy(function(a,b){e.add(j.valueKey?b[j.valueKey]:b),e.$input.typeahead("val","")},e))}e.$container.on("click",a.proxy(function(){e.$element.attr("disabled")||e.$input.removeAttr("disabled"),e.$input.focus()},e)),e.options.addOnBlur&&e.options.freeInput&&e.$input.on("focusout",a.proxy(function(){0===a(".typeahead, .twitter-typeahead",e.$container).length&&(e.add(e.$input.val()),e.$input.val(""))},e)),e.$container.on("keydown","input",a.proxy(function(b){var c=a(b.target),d=e.findInputWrapper();if(e.$element.attr("disabled"))return void e.$input.attr("disabled","disabled");switch(b.which){case 8:if(0===f(c[0])){var g=d.prev();g&&e.remove(g.data("item"))}break;case 46:if(0===f(c[0])){var h=d.next();h&&e.remove(h.data("item"))}break;case 37:var i=d.prev();0===c.val().length&&i[0]&&(i.before(d),c.focus());break;case 39:var j=d.next();0===c.val().length&&j[0]&&(j.after(d),c.focus())}{var k=c.val().length;Math.ceil(k/5)}c.attr("size",Math.max(this.inputSize,c.val().length))},e)),e.$container.on("keypress","input",a.proxy(function(b){var c=a(b.target);if(e.$element.attr("disabled"))return void e.$input.attr("disabled","disabled");var d=c.val(),f=e.options.maxChars&&d.length>=e.options.maxChars;e.options.freeInput&&(g(b,e.options.confirmKeys)||f)&&(e.add(f?d.substr(0,e.options.maxChars):d),c.val(""),b.preventDefault());{var h=c.val().length;Math.ceil(h/5)}c.attr("size",Math.max(this.inputSize,c.val().length))},e)),e.$container.on("click","[data-role=remove]",a.proxy(function(b){e.$element.attr("disabled")||e.remove(a(b.target).closest(".tag").data("item"))},e)),e.options.itemValue===h.itemValue&&("INPUT"===e.$element[0].tagName?e.add(e.$element.val()):a("option",e.$element).each(function(){e.add(a(this).attr("value"),!0)}))},destroy:function(){var a=this;a.$container.off("keypress","input"),a.$container.off("click","[role=remove]"),a.$container.remove(),a.$element.removeData("tagsinput"),a.$element.show()},focus:function(){this.$input.focus()},input:function(){return this.$input},findInputWrapper:function(){for(var b=this.$input[0],c=this.$container[0];b&&b.parentNode!==c;)b=b.parentNode;return a(b)}},a.fn.tagsinput=function(c,d){var e=[];return this.each(function(){var f=a(this).data("tagsinput");if(f)if(c||d){if(void 0!==f[c]){var g=f[c](d);void 0!==g&&e.push(g)}}else e.push(f);else f=new b(this,c),a(this).data("tagsinput",f),e.push(f),"SELECT"===this.tagName&&a("option",a(this)).attr("selected","selected"),a(this).val(a(this).val())}),"string"==typeof c?e.length>1?e:e[0]:e},a.fn.tagsinput.Constructor=b;var i=a("
");a(function(){a("input[data-role=tagsinput], select[multiple][data-role=tagsinput]").tagsinput()})}(window.jQuery); +//# sourceMappingURL=bootstrap-tagsinput.min.js.map \ No newline at end of file diff --git a/resources/views/bills/create.blade.php b/resources/views/bills/create.blade.php new file mode 100644 index 0000000000..f404cb4195 --- /dev/null +++ b/resources/views/bills/create.blade.php @@ -0,0 +1,63 @@ +@extends('layouts.default') +@section('content') +{!! Breadcrumbs::renderIfExists(Route::getCurrentRoute()->getName()) !!} +{!! Form::open(['class' => 'form-horizontal','id' => 'store','url' => route('bills.store')]) !!} + +
+
+ +
+
+ Mandatory fields +
+
+ {!! ExpandedForm::text('name') !!} + {!! ExpandedForm::tags('match') !!} + {!! ExpandedForm::amount('amount_min') !!} + {!! ExpandedForm::amount('amount_max') !!} + {!! ExpandedForm::date('date',Carbon\Carbon::now()->addDay()->format('Y-m-d')) !!} + {!! ExpandedForm::select('repeat_freq',$periods,'monthly') !!} +
+
+

+ +

+
+
+ +
+
+ Optional fields +
+
+ {!! ExpandedForm::integer('skip',0) !!} + {!! ExpandedForm::checkbox('automatch',1,true) !!} + {!! ExpandedForm::checkbox('active',1,true) !!} +
+
+ + +
+
+ Options +
+
+ {!! ExpandedForm::optionsList('create','bill') !!} +
+
+ +
+
+ +{!! Form::close() !!} + + +@stop +@section('styles') + +@stop +@section('scripts') + +@stop diff --git a/resources/views/bills/delete.blade.php b/resources/views/bills/delete.blade.php new file mode 100644 index 0000000000..fd9f04d5ad --- /dev/null +++ b/resources/views/bills/delete.blade.php @@ -0,0 +1,37 @@ +@extends('layouts.default') +@section('content') +{!! Breadcrumbs::renderIfExists(Route::getCurrentRoute()->getName(), $bill) !!} +{!! Form::open(['class' => 'form-horizontal','id' => 'destroy','url' => route('bills.destroy',$bill->id)]) !!} +
+
+
+
+ Delete bill "{{{$bill->name}}}" +
+
+

+ Are you sure? +

+ +

+ + Cancel +

+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ + +{!! Form::close() !!} +@stop diff --git a/resources/views/bills/edit.blade.php b/resources/views/bills/edit.blade.php new file mode 100644 index 0000000000..823d826011 --- /dev/null +++ b/resources/views/bills/edit.blade.php @@ -0,0 +1,64 @@ +@extends('layouts.default') +@section('content') +{!! Breadcrumbs::renderIfExists(Route::getCurrentRoute()->getName(), $bill) !!} +{!! Form::model($bill, ['class' => 'form-horizontal','id' => 'update','url' => route('bills.update', $bill->id)]) !!} + + + +
+
+ +
+
+ Mandatory fields +
+
+ {!! ExpandedForm::text('name') !!} + {!! ExpandedForm::tags('match') !!} + {!! ExpandedForm::amount('amount_min') !!} + {!! ExpandedForm::amount('amount_max') !!} + {!! ExpandedForm::date('date',$bill->date->format('Y-m-d')) !!} + {!! ExpandedForm::select('repeat_freq',$periods) !!} +
+
+ +

+ +

+
+
+ +
+
+ Optional fields +
+
+ {!! ExpandedForm::integer('skip') !!} + {!! ExpandedForm::checkbox('automatch',1) !!} + {!! ExpandedForm::checkbox('active',1) !!} + +
+
+
+
+ Options +
+
+ {!! ExpandedForm::optionsList('update','bill') !!} +
+ +
+
+
+{!! Form::close() !!} + + +@stop +@section('styles') + +@stop +@section('scripts') + +@stop diff --git a/resources/views/bills/index.blade.php b/resources/views/bills/index.blade.php new file mode 100644 index 0000000000..c9a465cc51 --- /dev/null +++ b/resources/views/bills/index.blade.php @@ -0,0 +1,32 @@ +@extends('layouts.default') +@section('content') +{!! Breadcrumbs::renderIfExists(Route::getCurrentRoute()->getName()) !!} +
+
+
+
+ {{{$title}}} + + +
+
+ + +
+
+
+
+ @include('list.bills') +
+
+
+
+@stop +@section('scripts') + +@stop diff --git a/resources/views/bills/show.blade.php b/resources/views/bills/show.blade.php new file mode 100644 index 0000000000..413606bb86 --- /dev/null +++ b/resources/views/bills/show.blade.php @@ -0,0 +1,116 @@ +@extends('layouts.default') +@section('content') +{!! Breadcrumbs::renderIfExists(Route::getCurrentRoute()->getName(), $bill) !!} +
+
+
+
+ {{{$bill->name}}} + + @if($bill->active) + + @else + + @endif + + @if($bill->automatch) + + @else + + @endif + + +
+
+ + +
+
+ +
+
+ + + + + + + + + +
+ Matching on + @foreach(explode(',',$bill->match) as $word) + {{{$word}}} + @endforeach + between {!! Amount::format($bill->amount_min) !!} and {!! Amount::format($bill->amount_max) !!}. + Repeats {!! $bill->repeat_freq !!}.
Next expected match + @if($bill->nextExpectedMatch) + {{$bill->nextExpectedMatch->format('j F Y')}} + @else + Unknown + @endif +
+
+
+
+
+
+
+ More +
+ +
+
+
+ +
+
+
+
+ Chart +
+
+
+
+
+
+
+ +
+
+
+
+ Connected transaction journals +
+
+ @include('list.journals-full') +
+
+
+
+ +@stop + +@section('scripts') + + + + + + + +@stop diff --git a/resources/views/list/bills.blade.php b/resources/views/list/bills.blade.php new file mode 100644 index 0000000000..a3a3ef7069 --- /dev/null +++ b/resources/views/list/bills.blade.php @@ -0,0 +1,71 @@ + + + + + + + + + + + + + @foreach($bills as $entry) + + + + + + + + + + + + + @endforeach +
 NameMatches onMatching amountLast seen matchNext expected matchIs activeWill be automatchedRepeats every
+
+ + +
+
+ {{{$entry->name}}} + + @foreach(explode(',',$entry->match) as $match) + {{{$match}}} + @endforeach + + {!! Amount::format($entry->amount_min) !!} + — + {!! Amount::format($entry->amount_max) !!} + + @if($entry->lastFoundMatch) + {{$entry->lastFoundMatch->format('j F Y')}} + @else + Unknown + @endif + + @if($entry->nextExpectedMatch) + {{$entry->nextExpectedMatch->format('j F Y')}} + @else + Unknown + @endif + + @if($entry->active) + + @else + + @endif + + @if($entry->automatch) + + @else + + @endif + + {{{$entry->repeat_freq}}} + @if($entry->skip > 0) + skips over {{$entry->skip}} + @endif +