Working on recurring invoices

This commit is contained in:
Hillel Coren 2013-12-10 19:18:35 +02:00
parent 78d2d749fb
commit 0611004e77
21 changed files with 381 additions and 103 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/app/config/staging
/app/config/development
/public/logo
/bootstrap/compiled.php
/vendor

View File

@ -3,50 +3,68 @@
use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputArgument;
use Ninja\Mailers\ContactMailer as Mailer;
class SendRecurringInvoices extends Command {
/**
* The console command name.
*
* @var string
*/
protected $name = 'ninja:send-invoices';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send recurring invoices';
protected $mailer;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
public function __construct(Mailer $mailer)
{
parent::__construct();
$this->mailer = $mailer;
}
/**
* Execute the console command.
*
* @return void
*/
public function fire()
{
$this->info('Running SendRecurringInvoices...');
$this->info(date('Y-m-d') . ' Running SendRecurringInvoices...');
$today = date('Y-m-d');
$invoices = Invoice::with('account', 'invoice_items')->whereRaw('start_date <= ? AND (end_date IS NULL OR end_date >= ?)', array($today, $today))->get();
$this->info(count($invoices) . ' recurring invoice(s) found');
foreach ($invoices as $recurInvoice)
{
$this->info('Processing Invoice ' . $recurInvoice->id . ' - Should send ' . ($recurInvoice->shouldSendToday() ? 'YES' : 'NO'));
if (!$recurInvoice->shouldSendToday())
{
continue;
}
$invoice = Invoice::createNew($recurInvoice);
$invoice->client_id = $recurInvoice->client_id;
$invoice->parent_id = $recurInvoice->id;
$invoice->invoice_number = $recurInvoice->account->getNextInvoiceNumber();
$invoice->total = $recurInvoice->total;
$invoice->invoice_date = new DateTime();
$invoice->due_date = new DateTime();
$invoice->save();
foreach ($recurInvoice->invoice_items as $recurItem)
{
$item = InvoiceItem::createNew($recurItem);
$item->product_id = $recurItem->product_id;
$item->qty = $recurItem->qty;
$item->cost = $recurItem->cost;
$item->notes = Utils::processVariables($recurItem->notes);
$item->product_key = Utils::processVariables($recurItem->product_key);
$invoice->invoice_items()->save($item);
}
$recurInvoice->last_sent_date = new DateTime();
$recurInvoice->save();
$this->mailer->sendInvoice($invoice, $invoice->client->contacts()->first());
}
$this->info('Done');
}
/**
* Get the console command arguments.
*
* @return array
*/
protected function getArguments()
{
return array(
@ -54,11 +72,6 @@ class SendRecurringInvoices extends Command {
);
}
/**
* Get the console command options.
*
* @return array
*/
protected function getOptions()
{
return array(

View File

@ -54,10 +54,10 @@ return array(
'mysql' => array(
'driver' => 'mysql',
'host' => 'localhost',
'database' => '',
'username' => '',
'password' => '',
'host' => getenv('DB_HOST'),
'database' => getenv('DB_NAME'),
'username' => getenv('DB_USER'),
'password' => getenv('DB_PASS'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',

View File

@ -1,12 +1,18 @@
<?php
use Ninja\Mailers\ContactMailer as Mailer;
class InvoiceController extends \BaseController {
/**
* Display a listing of the resource.
*
* @return Response
*/
protected $mailer;
public function __construct(Mailer $mailer)
{
parent::__construct();
$this->mailer = $mailer;
}
public function index()
{
return View::make('list', array(
@ -41,7 +47,7 @@ class InvoiceController extends \BaseController {
$table->addColumn('client', function($model) { return link_to('clients/' . $model->client_public_id, $model->client_name); });
}
return $table->addColumn('total', function($model){ return '$' . money_format('%i', $model->total); })
return $table->addColumn('total', function($model) { return '$' . money_format('%i', $model->total); })
->addColumn('balance', function($model) { return '$' . money_format('%i', $model->balance); })
->addColumn('invoice_date', function($model) { return Utils::fromSqlDate($model->invoice_date); })
->addColumn('due_date', function($model) { return Utils::fromSqlDate($model->due_date); })
@ -364,6 +370,7 @@ class InvoiceController extends \BaseController {
$invoice = Invoice::createNew();
}
$invoice->client_id = $client->id;
$invoice->invoice_number = trim(Input::get('invoice_number'));
$invoice->discount = 0;
$invoice->invoice_date = Utils::toSqlDate(Input::get('invoice_date'));
@ -443,21 +450,7 @@ class InvoiceController extends \BaseController {
if ($action == 'email')
{
$data = array('link' => URL::to('view') . '/' . $invoice->invoice_key);
/*
Mail::send(array('html'=>'emails.invoice_html','text'=>'emails.invoice_text'), $data, function($message) use ($contact)
{
$message->from('hillelcoren@gmail.com', 'Hillel Coren');
$message->to($contact->email);
});
*/
$invitation = Invitation::createNew();
$invitation->invoice_id = $invoice->id;
$invitation->user_id = Auth::user()->id;
$invitation->contact_id = $contact->id;
$invitation->invitation_key = str_random(20);
$invitation->save();
$this->mailer->sendInvoice($invoice, $contact);
Session::flash('message', 'Successfully emailed invoice');
} else {

View File

@ -21,15 +21,14 @@ class ConfideSetupUsersTable extends Migration {
Schema::dropIfExists('products');
Schema::dropIfExists('contacts');
Schema::dropIfExists('invoices');
Schema::dropIfExists('users');
Schema::dropIfExists('password_reminders');
Schema::dropIfExists('clients');
Schema::dropIfExists('users');
Schema::dropIfExists('accounts');
Schema::dropIfExists('invoice_statuses');
Schema::dropIfExists('countries');
Schema::dropIfExists('timezones');
Schema::create('countries', function($table)
{
$table->increments('id');
@ -147,8 +146,8 @@ class ConfideSetupUsersTable extends Migration {
Schema::create('clients', function($t)
{
$t->increments('id');
$t->unsignedInteger('user_id');
$t->unsignedInteger('account_id');
$t->unsignedInteger('country_id')->nullable();
$t->timestamps();
$t->softDeletes();
@ -158,12 +157,14 @@ class ConfideSetupUsersTable extends Migration {
$t->string('city');
$t->string('state');
$t->string('postal_code');
$t->unsignedInteger('country_id')->nullable();
$t->string('work_phone');
$t->text('notes');
$t->decimal('balance', 10, 2);
$t->timestamp('last_login');
$t->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade');
$t->foreign('user_id')->references('id')->on('users');
$t->foreign('country_id')->references('id')->on('countries');
$t->unsignedInteger('public_id');
@ -174,6 +175,7 @@ class ConfideSetupUsersTable extends Migration {
{
$t->increments('id');
$t->unsignedInteger('account_id');
$t->unsignedInteger('user_id');
$t->unsignedInteger('client_id');
$t->timestamps();
$t->softDeletes();
@ -186,6 +188,7 @@ class ConfideSetupUsersTable extends Migration {
$t->timestamp('last_login');
$t->foreign('client_id')->references('id')->on('clients')->onDelete('cascade');
$t->foreign('user_id')->references('id')->on('users');
$t->unsignedInteger('public_id');
$t->unique( array('account_id','public_id') );
@ -202,6 +205,7 @@ class ConfideSetupUsersTable extends Migration {
{
$t->increments('id');
$t->unsignedInteger('client_id');
$t->unsignedInteger('user_id');
$t->unsignedInteger('account_id');
$t->unsignedInteger('invoice_status_id')->default(1);
$t->timestamps();
@ -219,10 +223,14 @@ class ConfideSetupUsersTable extends Migration {
$t->integer('how_often');
$t->date('start_date')->nullable();
$t->date('end_date')->nullable();
$t->date('last_sent_date')->nullable();
$t->unsignedInteger('parent_id')->nullable();
$t->foreign('client_id')->references('id')->on('clients')->onDelete('cascade');
$t->foreign('account_id')->references('id')->on('accounts');
$t->foreign('user_id')->references('id')->on('users');
$t->foreign('invoice_status_id')->references('id')->on('invoice_statuses');
$t->foreign('parent_id')->references('id')->on('invoices');
$t->unsignedInteger('public_id');
$t->unique( array('account_id','public_id') );
@ -254,6 +262,7 @@ class ConfideSetupUsersTable extends Migration {
{
$t->increments('id');
$t->unsignedInteger('account_id');
$t->unsignedInteger('user_id');
$t->timestamps();
$t->softDeletes();
@ -263,6 +272,7 @@ class ConfideSetupUsersTable extends Migration {
$t->decimal('qty', 10, 2);
$t->foreign('account_id')->references('id')->on('accounts')->onDelete('cascade');
$t->foreign('user_id')->references('id')->on('users');
$t->unsignedInteger('public_id');
$t->unique( array('account_id','public_id') );
@ -273,6 +283,7 @@ class ConfideSetupUsersTable extends Migration {
{
$t->increments('id');
$t->unsignedInteger('account_id');
$t->unsignedInteger('user_id');
$t->unsignedInteger('invoice_id');
$t->unsignedInteger('product_id')->nullable();
$t->timestamps();
@ -285,6 +296,7 @@ class ConfideSetupUsersTable extends Migration {
$t->foreign('invoice_id')->references('id')->on('invoices')->onDelete('cascade');
$t->foreign('product_id')->references('id')->on('products');
$t->foreign('user_id')->references('id')->on('users');
$t->unsignedInteger('public_id');
$t->unique( array('account_id','public_id') );
@ -321,6 +333,7 @@ class ConfideSetupUsersTable extends Migration {
{
$t->increments('id');
$t->unsignedInteger('account_id');
$t->unsignedInteger('user_id');
$t->unsignedInteger('client_id')->nullable();
$t->unsignedInteger('contact_id')->nullable();
$t->timestamps();
@ -333,6 +346,7 @@ class ConfideSetupUsersTable extends Migration {
$t->foreign('account_id')->references('id')->on('accounts');
$t->foreign('client_id')->references('id')->on('clients')->onDelete('cascade');
$t->foreign('contact_id')->references('id')->on('contacts');
$t->foreign('user_id')->references('id')->on('users');
$t->unsignedInteger('public_id');
$t->unique( array('account_id','public_id') );
@ -380,9 +394,9 @@ class ConfideSetupUsersTable extends Migration {
Schema::dropIfExists('products');
Schema::dropIfExists('contacts');
Schema::dropIfExists('invoices');
Schema::dropIfExists('users');
Schema::dropIfExists('password_reminders');
Schema::dropIfExists('clients');
Schema::dropIfExists('users');
Schema::dropIfExists('accounts');
Schema::dropIfExists('invoice_statuses');
Schema::dropIfExists('countries');

View File

@ -133,5 +133,78 @@ class Utils
Session::put(RECENTLY_VIEWED, $viewed);
}
public static function processVariables($str)
{
if (!$str) {
return '';
}
$variables = ['MONTH', 'QUARTER', 'YEAR'];
for ($i=0; $i<count($variables); $i++)
{
$variable = $variables[$i];
$regExp = '/\[' . $variable . '[+-]?[\d]*\]/';
preg_match_all($regExp, $str, $matches);
$matches = $matches[0];
if (count($matches) == 0) {
continue;
}
foreach ($matches as $match) {
$offset = 0;
$addArray = explode('+', $match);
$minArray = explode('-', $match);
if (count($addArray) > 1) {
$offset = intval($addArray[1]);
} else if (count($minArray) > 1) {
$offset = intval($minArray[1]) * -1;
}
$val = Utils::getDatePart($variable, $offset);
$str = str_replace($match, $val, $str);
}
}
return $str;
}
private static function getDatePart($part, $offset)
{
$offset = intval($offset);
if ($part == 'MONTH') {
return Utils::getMonth($offset);
} else if ($part == 'QUARTER') {
return Utils::getQuarter($offset);
} else if ($part == 'YEAR') {
return Utils::getYear($offset);
}
}
private static function getMonth($offset)
{
$months = [ "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December" ];
$month = intval(date('n')) - 1;
$month += $offset;
$month = $month % 12;
return $months[$month];
}
private static function getQuarter($offset)
{
$month = intval(date('n')) - 1;
$quarter = floor(($month + 3) / 3);
$quarter += $offset;
$quarter = $quarter % 4;
if ($quarter == 0) {
$quarter = 4;
}
return 'Q' . $quarter;
}
private static function getYear($offset)
{
$year = intval(date('Y'));
return $year + $offset;
}
}

31
app/mailers/ContactMailer.php Executable file
View File

@ -0,0 +1,31 @@
<?php namespace Ninja\Mailers;
use Invoice;
use Contact;
use Invitation;
use URL;
use Auth;
class ContactMailer extends Mailer {
public function sendInvoice(Invoice $invoice, Contact $contact)
{
$view = 'invoice';
$data = array('link' => URL::to('view') . '/' . $invoice->invoice_key);
$subject = '';
if (Auth::check()) {
$invitation = Invitation::createNew();
} else {
$invitation = Invitation::createNew($invoice);
}
$invitation->invoice_id = $invoice->id;
$invitation->user_id = Auth::check() ? Auth::user()->id : $invoice->user_id;
$invitation->contact_id = $contact->id;
$invitation->invitation_key = str_random(20);
$invitation->save();
return $this->sendTo($contact->email, $subject, $view, $data);
}
}

19
app/mailers/Mailer.php Executable file
View File

@ -0,0 +1,19 @@
<?php namespace Ninja\Mailers;
use Mail;
abstract class Mailer {
public function sendTo($email, $subject, $view, $data = [])
{
$views = [
'html' => 'emails.'.$view.'_html',
'text' => 'emails.'.$view.'_text'
];
Mail::queue($views, $data, function($message) use($email, $subject)
{
$message->to($email)->subject($subject);
});
}
}

0
app/mailers/UserMailer.php Executable file
View File

View File

@ -79,7 +79,7 @@ class Account extends Eloquent
public function getNextInvoiceNumber()
{
$order = Invoice::withTrashed()->scope()->orderBy('invoice_number', 'DESC')->first();
$order = Invoice::withTrashed()->scope(false, $this->id)->orderBy('invoice_number', 'DESC')->first();
if ($order)
{

View File

@ -25,12 +25,19 @@ class Activity extends Eloquent
return $query->whereAccountId(Auth::user()->account_id);
}
private static function getBlank()
private static function getBlank($entity = false)
{
$user = Auth::user();
$activity = new Activity;
$activity->user_id = $user->id;
$activity->account_id = $user->account_id;
if (Auth::check()) {
$activity->user_id = Auth::user()->id;
$activity->account_id = Auth::user()->account_id;
} else if ($entity) {
$activity->user_id = $entity->user_id;
$activity->account_id = $entity->account_id;
} else {
exit; // TODO_FIX log error
}
return $activity;
}
@ -56,11 +63,12 @@ class Activity extends Eloquent
public static function createInvoice($invoice)
{
$activity = Activity::getBlank();
$userName = Auth::check() ? Auth::user()->getFullName() : '<i>System</i>';
$activity = Activity::getBlank($invoice);
$activity->invoice_id = $invoice->id;
$activity->client_id = $invoice->client_id;
$activity->activity_type_id = ACTIVITY_TYPE_CREATE_INVOICE;
$activity->message = Auth::user()->getFullName() . ' created invoice ' . link_to('invoices/'.$invoice->public_id, $invoice->invoice_number);
$activity->message = $userName . ' created invoice ' . link_to('invoices/'.$invoice->public_id, $invoice->invoice_number);
$activity->save();
}
@ -76,12 +84,13 @@ class Activity extends Eloquent
public static function emailInvoice($invitation)
{
$activity = Activity::getBlank();
$userName = Auth::check() ? Auth::user()->getFullName() : '<i>System</i>';
$activity = Activity::getBlank($invitation);
$activity->client_id = $invitation->invoice->client_id;
$activity->invoice_id = $invitation->invoice_id;
$activity->contact_id = $invitation->contact_id;
$activity->activity_type_id = ACTIVITY_TYPE_EMAIL_INVOICE;
$activity->message = Auth::user()->getFullName() . ' emailed invoice ' . link_to('invoices/'.$invitation->invoice->public_id, $invitation->invoice->invoice_number) . ' to ' . $invitation->contact->getFullName();
$activity->message = $userName . ' emailed invoice ' . link_to('invoices/'.$invitation->invoice->public_id, $invitation->invoice->invoice_number) . ' to ' . $invitation->contact->getFullName();
$activity->save();
}

View File

@ -5,13 +5,22 @@ class EntityModel extends Eloquent
protected $softDelete = true;
protected $hidden = array('id', 'created_at', 'updated_at', 'deleted_at');
public static function createNew()
public static function createNew($parent = false)
{
$className = get_called_class();
$entity = new $className();
$entity->account_id = Auth::user()->account_id;
$lastEntity = $className::scope()->orderBy('public_id', 'DESC')->first();
if (Auth::check()) {
$entity->user_id = Auth::user()->id;
$entity->account_id = Auth::user()->account_id;
} else if ($parent) {
$entity->user_id = $parent->user_id;
$entity->account_id = $parent->account_id;
} else {
exit; // TODO_FIX
}
$lastEntity = $className::scope(false, $entity->account_id)->orderBy('public_id', 'DESC')->first();
if ($lastEntity)
{
@ -36,9 +45,12 @@ class EntityModel extends Eloquent
return '';
}
public function scopeScope($query, $publicId = false)
public function scopeScope($query, $publicId = false, $accountId = false)
{
$query->whereAccountId(Auth::user()->account_id);
if (!$accountId) {
$accountId = Auth::user()->account_id;
}
$query->whereAccountId($accountId);
if ($publicId)
{

View File

@ -39,19 +39,49 @@ class Invoice extends EntityModel
return $this->how_often || $this->start_date || $this->end_date;
}
/*
public function getTotal()
public function shouldSendToday()
{
$total = 0;
$dayOfWeekToday = date('w');
$dayOfWeekStart = date('w', strtotime($this->start_date));
foreach ($this->invoice_items as $invoiceItem)
{
$total += $invoiceItem->qty * $invoiceItem->cost;
$dayOfMonthToday = date('j');
$dayOfMonthStart = date('j', strtotime($this->start_date));
if (!$this->last_sent_date) {
$daysSinceLastSent = 0;
$monthsSinceLastSent = 0;
} else {
$date1 = new DateTime($this->last_sent_date);
$date2 = new DateTime();
$diff = $date2->diff($date1);
$daysSinceLastSent = $diff->format("%a");
$monthsSinceLastSent = ($diff->format('%y') * 12) + $diff->format('%m');
if ($daysSinceLastSent == 0) {
return false;
}
}
return $total;
switch ($this->how_often)
{
case FREQUENCY_WEEKLY:
return $dayOfWeekStart == $dayOfWeekToday;
case FREQUENCY_TWO_WEEKS:
return $dayOfWeekStart == $dayOfWeekToday && (!$daysSinceLastSent || $daysSinceLastSent == 14);
case FREQUENCY_FOUR_WEEKS:
return $dayOfWeekStart == $dayOfWeekToday && (!$daysSinceLastSent || $daysSinceLastSent == 28);
case FREQUENCY_MONTHLY:
return $dayOfMonthStart == $dayOfMonthToday || $daysSinceLastSent > 31;
case FREQUENCY_THREE_MONTHS:
return ($dayOfMonthStart == $dayOfMonthToday && (!$daysSinceLastSent || $monthsSinceLastSent == 3)) || $daysSinceLastSent > (3 * 31);
case FREQUENCY_SIX_MONTHS:
return ($dayOfMonthStart == $dayOfMonthToday && (!$daysSinceLastSent || $monthsSinceLastSent == 6)) || $daysSinceLastSent > (6 * 31);
case FREQUENCY_ANNUALLY:
return ($dayOfMonthStart == $dayOfMonthToday && (!$daysSinceLastSent || $monthsSinceLastSent == 12)) || $daysSinceLastSent > (12 *31);
}
return false;
}
*/
}
Invoice::created(function($invoice)

View File

@ -1,6 +1,6 @@
<?php
class Theme extends EntityModel
class Theme extends Eloquent
{
public $timestamps = false;
protected $softDelete = false;

View File

@ -13,6 +13,7 @@
//dd(DB::getQueryLog());
//dd(Client::getPrivateId(1));
//dd(new DateTime());
Route::get('/', 'HomeController@showWelcome');
Route::post('get_started', 'AccountController@getStarted');

View File

@ -11,5 +11,4 @@
|
*/
//Artisan::add(new SendRecurringInvoices);
Artisan::resolve('SendRecurringInvoices');

View File

@ -256,7 +256,7 @@
refreshPDF();
});
$('#due_date,#start_date').datepicker({
$('#due_date, #start_date, #end_date').datepicker({
autoclose: true,
todayHighlight: true
});

View File

@ -28,9 +28,8 @@ $app->redirectIfTrailingSlash();
$env = $app->detectEnvironment(array(
'local' => array('precise64'),
'development' => array('precise64'),
'staging' => array('host107.hostmonster.com')
));
/*

View File

@ -23,7 +23,8 @@
"app/database/migrations",
"app/database/seeds",
"app/tests/TestCase.php",
"app/libraries"
"app/libraries",
"app/mailers"
]
},
"scripts": {

View File

@ -96,6 +96,10 @@ function generatePDF(invoice) {
}
shownItem = true;
// process date variables
notes = processVariables(notes);
productKey = processVariables(productKey);
var lineTotal = item.cost * item.qty;
if (lineTotal) total += lineTotal;
lineTotal = formatNumber(lineTotal);
@ -200,6 +204,77 @@ function formatNumber(num) {
}, "") + "." + p[1];
}
/* Handle converting variables in the invoices (ie, MONTH+1) */
function processVariables(str) {
if (!str) return '';
var variables = ['MONTH','QUARTER','YEAR'];
for (var i=0; i<variables.length; i++) {
var variable = variables[i];
var regexp = new RegExp('\\[' + variable + '[+-]?[\\d]*\\]', 'g');
var matches = str.match(regexp);
if (!matches) {
continue;
}
for (var j=0; j<matches.length; j++) {
var match = matches[j];
var offset = 0;
if (match.split('+').length > 1) {
offset = match.split('+')[1];
} else if (match.split('-').length > 1) {
offset = parseInt(match.split('-')[1]) * -1;
}
str = str.replace(match, getDatePart(variable, offset));
}
}
return str;
}
function getDatePart(part, offset) {
offset = parseInt(offset);
if (!offset) {
offset = 0;
}
if (part == 'MONTH') {
return getMonth(offset);
} else if (part == 'QUARTER') {
return getQuarter(offset);
} else if (part == 'YEAR') {
return getYear(offset);
}
}
function getMonth(offset) {
var today = new Date();
var months = [ "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December" ];
var month = today.getMonth();
month = parseInt(month) + offset;
month = month % 12;
return months[month];
}
function getYear(offset) {
var today = new Date();
var year = today.getFullYear();
return parseInt(year) + offset;
}
function getQuarter(offset) {
var today = new Date();
var quarter = Math.floor((today.getMonth() + 3) / 3);
quarter += offset;
quarter = quarter % 4;
if (quarter == 0) {
quarter = 4;
}
return 'Q' + quarter;
}
function formatMoney(num) {
num = parseFloat(num);
if (!num) return '$0.00';

8
scheduler.yml Executable file
View File

@ -0,0 +1,8 @@
SendRecurringInvoicesCron:
type: cron
script: htdocs/artisan
args:
- "ninja:send-invoices"
interval:
minute: 0
hour: *