Stuff for recurring transactions [skip ci]

This commit is contained in:
James Cole 2014-08-07 07:44:37 +02:00
parent 23d69c0dd9
commit cdd5a6c225
19 changed files with 523 additions and 29 deletions

View File

@ -0,0 +1,14 @@
// This is a manifest file that'll be compiled into application.js, which will include all the files
// listed below.
//
// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
// can be referenced here using a relative path.
//
// It's not advisable to add code directly here, but if you do, it'll appear in whatever order it
// gets included (e.g. say you have require_tree . then the code will appear after all the directories
// but before any files alphabetically greater than 'application.js'
//
// The available directives right now are require, require_directory, and require_tree
//
//= require tagsinput/bootstrap-tagsinput.min
//= require firefly/recurring

View File

@ -0,0 +1,48 @@
.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%;
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);
}
.bootstrap-tagsinput {width:100%;}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
/**
* This is a manifest file that'll be compiled into application.css, which will include all the files
* listed below.
*
* Any Css/Less files within this directory, lib/assets/javascripts, vendor/assets/javascripts,
* can be referenced here using a relative path.
*
* It's not advisable to add code directly here, but if you do, it'll appear in whatever order it
* gets included (e.g. say you have require_tree . then the code will appear after all the directories
* but before any files alphabetically greater than 'application.css'
*
*= require tagsinput/bootstrap-tagsinput
*/

View File

@ -5,21 +5,36 @@ use Firefly\Storage\RecurringTransaction\RecurringTransactionRepositoryInterface
class RecurringController extends BaseController
{
protected $_repository;
public function __construct(RTR $repository)
{
$this->_repository = $repository;
View::share('menu', 'home');
}
public function create()
{
$periods = \Config::get('firefly.periods_to_text');
return View::make('recurring.create')->with('periods', $periods);
}
public function delete()
public function delete(RecurringTransaction $recurringTransaction)
{
return View::make('recurring.delete')->with('recurringTransaction', $recurringTransaction);
}
public function destroy()
public function destroy(RecurringTransaction $recurringTransaction)
{
$result = $this->_repository->destroy($recurringTransaction);
if ($result === true) {
Session::flash('success', 'The recurring transaction was deleted.');
} else {
Session::flash('error', 'Could not delete the recurring transaction. Check the logs to be sure.');
}
return Redirect::route('recurring.index');
}
public function edit()
@ -29,7 +44,8 @@ class RecurringController extends BaseController
public function index()
{
$list = $this->_repository->get();
return View::make('recurring.index');
return View::make('recurring.index')->with('list', $list);
}
public function show()
@ -38,6 +54,21 @@ class RecurringController extends BaseController
public function store()
{
$recurringTransaction = $this->_repository->store(Input::all());
if ($recurringTransaction->id) {
Session::flash('success', 'Recurring transaction "' . $recurringTransaction->name . '" saved!');
if (Input::get('create') == '1') {
return Redirect::route('recurring.create')->withInput();
} else {
return Redirect::route('recurring.index');
}
} else {
Session::flash(
'error', 'Could not save the recurring transaction: ' . $recurringTransaction->errors()->first()
);
return Redirect::route('recurring.create')->withInput()->withErrors($recurringTransaction->errors());
}
}
public function update()

View File

@ -134,15 +134,23 @@ class Budget implements BudgetInterface
}
$query = $budget->transactionjournals()->with(
'transactions', 'transactions.account', 'components', 'transactiontype',
'transactions.account.accounttype'
)->whereNotIn(
'transaction_journals.id', $inRepetition
if (count($inRepetition) > 0) {
$query = $budget->transactionjournals()->with(
'transactions', 'transactions.account', 'components', 'transactiontype',
'transactions.account.accounttype'
)->whereNotIn(
'transaction_journals.id', $inRepetition
)->orderBy('date', 'DESC')->orderBy(
'transaction_journals.id', 'DESC'
);
} else {
$query = $budget->transactionjournals()->with(
'transactions', 'transactions.account', 'components', 'transactiontype',
'transactions.account.accounttype'
)->orderBy('date', 'DESC')->orderBy(
'transaction_journals.id', 'DESC'
);
'transaction_journals.id', 'DESC'
);
}
// build paginator:
$perPage = 25;

View File

@ -124,7 +124,7 @@ class EloquentBudgetRepository implements BudgetRepositoryInterface
}
$limit->startdate = $startDate;
$limit->amount = $data['amount'];
$limit->repeats = $data['repeats'];
$limit->repeats = isset($data['repeats']) ? $data['repeats'] : 0;
$limit->repeat_freq = $data['repeat_freq'];
if ($limit->validate()) {
$limit->save();

View File

@ -7,9 +7,41 @@ use Carbon\Carbon;
class EloquentRecurringTransactionRepository implements RecurringTransactionRepositoryInterface
{
public function destroy(\RecurringTransaction $recurringTransaction) {
$recurringTransaction->delete();
return true;
}
public function get() {
public function get()
{
return \Auth::user()->recurringtransactions()->get();
}
public function store($data)
{
$recurringTransaction = new \RecurringTransaction;
$recurringTransaction->user()->associate(\Auth::user());
$recurringTransaction->name = $data['name'];
$recurringTransaction->match = join(' ', explode(',', $data['match']));
$recurringTransaction->amount_max = floatval($data['amount_max']);
$recurringTransaction->amount_min = floatval($data['amount_min']);
// both amounts zero:
if($recurringTransaction->amount_max == 0 && $recurringTransaction->amount_min == 0) {
$recurringTransaction->errors()->add('amount_max','Amount max and min cannot both be zero.');
return $recurringTransaction;
}
$recurringTransaction->date = new Carbon($data['date']);
$recurringTransaction->active = isset($data['active']) ? intval($data['active']) : 0;
$recurringTransaction->automatch = isset($data['automatch']) ? intval($data['automatch']) : 0;
$recurringTransaction->skip = isset($data['skip']) ? intval($data['skip']) : 0;
$recurringTransaction->repeat_freq = $data['repeat_freq'];
if($recurringTransaction->validate()) {
$recurringTransaction->save();
}
return $recurringTransaction;
}
}

View File

@ -9,5 +9,9 @@ interface RecurringTransactionRepositoryInterface
public function get();
public function store($data);
public function destroy(\RecurringTransaction $recurringTransaction);
}

View File

@ -392,7 +392,9 @@ class EloquentTransactionJournalRepository implements TransactionJournalReposito
// do budget:
$budget = $budRepository->find($data['budget_id']);
$journal->budgets()->attach($budget);
if(!is_null($budget)) {
$journal->budgets()->attach($budget);
}
break;
case 'Deposit':

View File

@ -6,28 +6,64 @@ class RecurringTransaction extends Ardent
public static $rules
= [
'user_id' => 'required|exists:users,id',
'name' => 'required|between:1,255',
'match' => 'required',
'amount_max' => 'required|between:0,65536',
'amount_min' => 'required|between:0,65536',
'date' => 'required|date',
'active' => 'required|between:0,1',
'automatch' => 'required|between:0,1',
'user_id' => 'required|exists:users,id',
'name' => 'required|between:1,255',
'match' => 'required',
'amount_max' => 'required|between:0,65536',
'amount_min' => 'required|between:0,65536',
'date' => 'required|date',
'active' => 'required|between:0,1',
'automatch' => 'required|between:0,1',
'repeat_freq' => 'required|in:daily,weekly,monthly,quarterly,half-year,yearly',
'skip' => 'required|between:0,31',
'skip' => 'required|between:0,31',
];
public static $factory
= [
'user_id' => 'factory|User',
'name' => 'string',
'data' => 'string'
'name' => 'string',
'data' => 'string'
];
public function getDates()
{
return ['created_at', 'updated_at', 'date'];
}
public function next()
{
$start = clone $this->date;
$skip = $this->skip == 0 ? 1 : $this->skip;
while ($start <= $this->date) {
switch ($this->repeat_freq) {
case 'daily':
$start->addDays($skip);
break;
case 'weekly':
$start->addWeeks($skip);
break;
case 'monthly':
$start->addMonths($skip);
break;
case 'quarterly':
$start->addMonths($skip);
break;
case 'half-year':
$start->addMonths($skip * 6);
break;
case 'yearly':
$this->addYears($skip);
break;
}
}
return $start;
}
public function user()
{
return $this->belongsTo('User');
}
}

View File

@ -20,6 +20,17 @@ Route::bind('accountname', function($value, $route)
}
return null;
});
Route::bind('recurring', function($value, $route)
{
if(Auth::check()) {
return RecurringTransaction::
where('id', $value)->
where('user_id',Auth::user()->id)->first();
}
return null;
});
Route::bind('budget', function($value, $route)
{
if(Auth::check()) {
@ -136,6 +147,9 @@ Route::group(['before' => 'auth'], function () {
// recurring transactions controller
Route::get('/recurring',['uses' => 'RecurringController@index', 'as' => 'recurring.index']);
Route::get('/recurring/create',['uses' => 'RecurringController@create', 'as' => 'recurring.create']);
Route::get('/recurring/edit/{recurring}',['uses' => 'RecurringController@edit','as' => 'recurring.edit']);
Route::get('/recurring/delete/{recurring}',['uses' => 'RecurringController@delete','as' => 'recurring.delete']);
// transaction controller:
Route::get('/transactions/create/{what}', ['uses' => 'TransactionController@create', 'as' => 'transactions.create'])->where(['what' => 'withdrawal|deposit|transfer']);
@ -187,6 +201,11 @@ Route::group(['before' => 'csrf|auth'], function () {
// profile controller
Route::post('/profile/change-password', ['uses' => 'ProfileController@postChangePassword']);
// recurring controller
Route::post('/recurring/store',['uses' => 'RecurringController@store', 'as' => 'recurring.store']);
Route::post('/recurring/update/{recurring}',['uses' => 'RecurringController@update','as' => 'recurring.update']);
Route::post('/recurring/destroy/{recurring}',['uses' => 'RecurringController@destroy','as' => 'recurring.destroy']);
// transaction controller:
Route::post('/transactions/store/{what}', ['uses' => 'TransactionController@store', 'as' => 'transactions.store'])->where(['what' => 'withdrawal|deposit|transfer']);
Route::post('/transaction/update/{tj}',['uses' => 'TransactionController@update','as' => 'transactions.update']);

View File

@ -35,7 +35,7 @@
<td>Out</td>
<td>
{{mf($show['statistics']['period']['out'])}}
<a href="#transactions-thisaccount-this-period-expensesonly"><span class="glyphicon glyphicon-circle-arrow-right"></span></a>
<a href="{{route('accounts.show',$account->id)}}#transactions-thisaccount-this-period-expensesonly"><span class="glyphicon glyphicon-circle-arrow-right"></span></a>
</td>
<td>
{{mf($show['statistics']['period']['t_out'])}}

View File

@ -74,7 +74,9 @@
</div>
<div class="col-sm-3">
<small>
<a href="{{route('budgets.show',$budget->id)}}?rep={{$rep->id}}">
{{$rep->periodShow()}}
</a>
</small>
</div>
@if($limit->repeats == 1)

View File

@ -28,7 +28,7 @@
<td>
@foreach($journal->components as $component)
@if($component->class == 'Budget')
<a href="#budget-overview-in-month"><span class="glyphicon glyphicon-tasks" title="Budget: {{{$component->name}}}"></span></a>
<a href="{{route('budgets.show',$component->id)}}#GETTHEREPSOMEHOW_ORLIMITQUERYbudget-overview-in-month"><span class="glyphicon glyphicon-tasks" title="Budget: {{{$component->name}}}"></span></a>
@endif
@if($component->class == 'Category')
<a href="#category-overview-in-month"><span class="glyphicon glyphicon-tag" title="Category: {{{$component->name}}}"></span></a>

View File

@ -0,0 +1,196 @@
@extends('layouts.default')
@section('content')
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12">
<h1>Firefly
<small>Create a recurring transaction</small>
</h1>
<p class="lead">Use recurring transactions to track repeated expenses</p>
<p class="text-info">
Bla bla.
</p>
</div>
</div>
{{Form::open(['class' => 'form-horizontal','url' => route('recurring.store')])}}
<div class="row">
<div class="col-lg-6 col-md-12 col-sm-6">
<h4>Mandatory fields</h4>
<!-- name -->
<div class="form-group">
<label for="name" class="col-sm-4 control-label">Name</label>
<div class="col-sm-8">
<input type="text" name="name" class="form-control" id="name" value="{{Input::old('name')}}" placeholder="Name">
@if($errors->has('name'))
<p class="text-danger">{{$errors->first('name')}}</p>
@else
<span class="help-block">For example: rent, gas, insurance</span>
@endif
</div>
</div>
<div class="form-group">
<label for="match" class="col-sm-4 control-label">Matches on</label>
<div class="col-sm-8">
<input type="text" name="match" class="form-control" id="match" value="{{Input::old('match')}}" data-role="tagsinput">
@if($errors->has('match'))
<p class="text-danger">{{$errors->first('match')}}</p>
@else
<span class="help-block">For example: rent, [company name]. All matches need to
be present for the recurring transaction to be recognized. This field is not case-sensitive.</span>
@endif
</div>
</div>
<div class="form-group">
{{ Form::label('amount_min', 'Minimum amount', ['class' => 'col-sm-4 control-label'])}}
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon">&euro;</span>
{{Form::input('number','amount_min', Input::old('amount_min'), ['step' => 'any', 'class' => 'form-control'])}}
</div>
@if($errors->has('amount_min'))
<p class="text-danger">{{$errors->first('amount_min')}}</p>
@else
<span class="help-block">Firefly will only include transactions with a higher amount than this. If your rent
is usually around &euro; 500,-, enter <code>450</code> to be safe.</span>
@endif
</div>
</div>
<div class="form-group">
{{ Form::label('amount_max', 'Maximum amount', ['class' => 'col-sm-4 control-label'])}}
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon">&euro;</span>
{{Form::input('number','amount_max', Input::old('amount_max'), ['step' => 'any', 'class' => 'form-control'])}}
</div>
@if($errors->has('amount_max'))
<p class="text-danger">{{$errors->first('amount_max')}}</p>
@else
<span class="help-block">Firefly will only include transactions with a lower amount than this. If your rent
is usually around &euro; 500,-, enter <code>550</code> to be safe.</span>
@endif
</div>
</div>
<div class="form-group">
{{ Form::label('date', 'Date', ['class' => 'col-sm-4 control-label'])}}
<div class="col-sm-8">
{{ Form::input('date','date', Input::old('date') ?: date('Y-m-d'), ['class'
=> 'form-control']) }}
@if($errors->has('date'))
<p class="text-danger">{{$errors->first('date')}}</p>
@else
<span class="help-block">Select the next date you expect the transaction to occur.</span>
@endif
</div>
</div>
<div class="form-group">
<label for="period" class="col-sm-4 control-label">Recurrence</label>
<div class="col-sm-8">
{{Form::select('repeat_freq',$periods,Input::old('repeat_freq') ?: 'monthly',['class' => 'form-control'])}}
@if($errors->has('repeat_freq'))
<p class="text-danger">{{$errors->first('repeat_freq')}}</p>
@else
<span class="help-block">Select the period over which this transaction repeats</span>
@endif
</div>
</div>
</div>
<div class="col-lg-6 col-md-12 col-sm-6">
<h4>Optional fields</h4>
<div class="form-group">
{{ Form::label('skip', 'Skip', ['class' => 'col-sm-4 control-label'])}}
<div class="col-sm-8">
{{Form::input('number','skip', Input::old('skip') ?: 0, ['class' => 'form-control'])}}
@if($errors->has('skip'))
<p class="text-danger">{{$errors->first('skip')}}</p>
@else
<span class="help-block">Make Firefly skip every <em>n</em> times. Fill in <code>2</code>, and Firefly
will match, skip, skip and match a transaction.</span>
@endif
</div>
</div>
<!-- select budget -->
<!-- select category -->
<!-- select beneficiary -->
<div class="form-group">
<label for="automatch" class="col-sm-4 control-label">Auto-match</label>
<div class="col-sm-8">
<div class="checkbox">
<label>
{{Form::checkbox('automatch',1,Input::old('automatch') == '1' || !Input::old('automatch'))}}
Yes
</label>
</div>
<span class="help-block">Firefly will automatically match transactions.</span>
</div>
</div>
<div class="form-group">
<label for="active" class="col-sm-4 control-label">Active</label>
<div class="col-sm-8">
<div class="checkbox">
<label>
{{Form::checkbox('active',1,Input::old('active') == '1' || !Input::old('active'))}}
Yes
</label>
</div>
<span class="help-block">This recurring transaction is actually active.</span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-md-12 col-sm-6">
<!-- add another after this one? -->
<div class="form-group">
<label for="create" class="col-sm-4 control-label">&nbsp;</label>
<div class="col-sm-8">
<div class="checkbox">
<label>
{{Form::checkbox('create',1,Input::old('create') == '1')}}
Create another (return to this form)
</label>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-4 col-sm-8">
<button type="submit" class="btn btn-default btn-success">Create the recurring transaction</button>
</div>
</div>
</div>
</div>
{{Form::close()}}
@stop
@section('styles')
<?php echo stylesheet_link_tag('recurring'); ?>
@stop
@section('scripts')
<?php echo javascript_include_tag('recurring'); ?>
@stop

View File

@ -0,0 +1,37 @@
@extends('layouts.default')
@section('content')
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12">
<h1>Firefly
<small>Delete recurring transaction "{{{$recurringTransaction->name}}}"</small>
</h1>
<p class="lead">
Remember that deleting something is permanent.
</p>
</div>
</div>
{{Form::open(['class' => 'form-horizontal','url' => route('recurring.destroy',$recurringTransaction->id)])}}
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12">
<p class="text-danger">
Press "Delete permanently" If you are sure you want to delete "{{{$recurringTransaction->name}}}".
</p>
</div>
</div>
<div class="row">
<div class="col-lg-6">
<div class="form-group">
<div class="col-sm-8">
<button type="submit" class="btn btn-default btn-danger">Delete permanently</button>
<a href="{{route('recurring.index')}}" class="btn-default btn">Cancel</a>
</div>
</div>
</div>
</div>
{{Form::close()}}
@stop

View File

@ -5,6 +5,7 @@
<h1>Firefly
<small>Recurring transactions</small>
</h1>
<p class="lead">Use recurring transactions to track repeated expenses</p>
<p class="text-info">We all have bills to pay. Firefly can help you organize those bills into recurring transactions,
which are exactly what the name suggests. Firefly can match new (and existing) transactions to such a recurring transaction
and help you organize these expenses into manageable groups. The front page of Firefly will show you which recurring
@ -22,10 +23,54 @@
<th>Amount between</th>
<th>Expected every</th>
<th>Next expected match</th>
<th>Automatch</th>
<th>Auto-match</th>
<th>Active</th>
<th></th>
</tr>
@foreach($list as $entry)
<tr>
<td><a href="#">{{{$entry->name}}}</a></td>
<td>
@foreach(explode(' ',$entry->match) as $word)
<span class="label label-default">{{{$word}}}</span>
@endforeach
</td>
<td>
{{mf($entry->amount_min)}} &ndash;
{{mf($entry->amount_max)}}
</td>
<td>
{{$entry->repeat_freq}}
</td>
<td>
{{$entry->next()->format('d-m-Y')}}
</td>
<td>
@if($entry->automatch)
<span class="glyphicon glyphicon-ok"></span>
@else
<span class="glyphicon glyphicon-remove"></span>
@endif
</td>
<td>
@if($entry->active)
<span class="glyphicon glyphicon-ok"></span>
@else
<span class="glyphicon glyphicon-remove"></span>
@endif
</td>
<td>
<div class="btn-group btn-group-xs">
<a href="{{route('recurring.edit',$entry->id)}}" class="btn btn-default"><span class="glyphicon glyphicon-pencil"></span></a>
<a href="{{route('recurring.delete',$entry->id)}}" class="btn btn-danger"><span class="glyphicon glyphicon-trash"></span></a>
</div>
</td>
</tr>
@endforeach
</table>
<p>
<a href="{{route('recurring.create')}}" class="btn btn-success btn-large">Create new recurring transaction</a>
</p>
</div>
</div>
@stop