1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-08 12:12:48 +01:00

Merge branch 'release-4.3.0'

This commit is contained in:
Hillel Coren 2018-03-27 17:15:14 +03:00
commit 04eec340f2
240 changed files with 7254 additions and 3310 deletions

View File

@ -2,6 +2,7 @@
RewriteEngine On
RewriteRule "^.env" - [F,L]
RewriteRule "^storage" - [F,L]
RewriteRule ^(.well-known)($|/) - [L]
# https://coderwall.com/p/erbaig/laravel-s-htaccess-to-remove-public-from-url
# RewriteRule ^(.*)$ public/$1 [L]

View File

@ -6,13 +6,9 @@ sudo: true
group: deprecated-2017Q4
php:
# - 5.5.9
# - 5.6
# - 5.6
# - 7.0
- 7.1
# - 7.2
# - hhvm
- 7.2
addons:
hosts:
@ -114,15 +110,17 @@ after_script:
- mysql -u root -e 'select * from account_gateways;' ninja
- mysql -u root -e 'select * from clients;' ninja
- mysql -u root -e 'select * from contacts;' ninja
- mysql -u root -e 'select * from invoices;' ninja
- mysql -u root -e 'select id, account_id, invoice_number, amount, balance from invoices;' ninja
- mysql -u root -e 'select * from invoice_items;' ninja
- mysql -u root -e 'select * from invitations;' ninja
- mysql -u root -e 'select * from payments;' ninja
- mysql -u root -e 'select id, account_id, invoice_id, amount, transaction_reference from payments;' ninja
- mysql -u root -e 'select * from credits;' ninja
- mysql -u root -e 'select * from expenses;' ninja
- mysql -u root -e 'select * from accounts;' ninja
- mysql -u root -e 'select * from fonts;' ninja
- mysql -u root -e 'select * from banks;' ninja
- mysql -u root -e 'select * from account_gateway_tokens;' ninja
- mysql -u root -e 'select * from payment_methods;' ninja
- FILES=$(find tests/_output -type f -name '*.png' | sort -nr)
- for i in $FILES; do echo $i; base64 "$i"; break; done
@ -131,3 +129,5 @@ notifications:
email:
on_success: never
on_failure: change
slack:
invoiceninja: SLraaKBDvjeRuRtY9o3Yvp1b

View File

@ -6,7 +6,6 @@
[![Build Status](https://travis-ci.org/invoiceninja/invoiceninja.svg?branch=master)](https://travis-ci.org/invoiceninja/invoiceninja)
[![Docs](https://readthedocs.org/projects/invoice-ninja/badge/?version=latest)](http://docs.invoiceninja.com/en/latest/?badge=latest)
[![Join the chat at https://gitter.im/hillelcoren/invoice-ninja](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/hillelcoren/invoice-ninja?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
## [Hosted](https://www.invoiceninja.com) | [Self-Hosted](https://www.invoiceninja.org) | [iPhone](https://itunes.apple.com/WebObjects/MZStore.woa/wa/viewSoftware?id=1220337560&mt=8) | [Android](https://play.google.com/store/apps/details?id=com.invoiceninja.invoiceninja)

View File

@ -5,10 +5,10 @@ namespace App\Console\Commands;
use App\Models\Account;
use App\Models\Invoice;
use App\Models\RecurringExpense;
use App\Ninja\Mailers\ContactMailer as Mailer;
use App\Ninja\Repositories\InvoiceRepository;
use App\Ninja\Repositories\RecurringExpenseRepository;
use App\Services\PaymentService;
use App\Jobs\SendInvoiceEmail;
use DateTime;
use Illuminate\Console\Command;
use Symfony\Component\Console\Input\InputOption;
@ -31,11 +31,6 @@ class SendRecurringInvoices extends Command
*/
protected $description = 'Send recurring invoices';
/**
* @var Mailer
*/
protected $mailer;
/**
* @var InvoiceRepository
*/
@ -49,15 +44,13 @@ class SendRecurringInvoices extends Command
/**
* SendRecurringInvoices constructor.
*
* @param Mailer $mailer
* @param InvoiceRepository $invoiceRepo
* @param PaymentService $paymentService
*/
public function __construct(Mailer $mailer, InvoiceRepository $invoiceRepo, PaymentService $paymentService, RecurringExpenseRepository $recurringExpenseRepo)
public function __construct(InvoiceRepository $invoiceRepo, PaymentService $paymentService, RecurringExpenseRepository $recurringExpenseRepo)
{
parent::__construct();
$this->mailer = $mailer;
$this->invoiceRepo = $invoiceRepo;
$this->paymentService = $paymentService;
$this->recurringExpenseRepo = $recurringExpenseRepo;
@ -115,9 +108,9 @@ class SendRecurringInvoices extends Command
try {
$invoice = $this->invoiceRepo->createRecurringInvoice($recurInvoice);
if ($invoice && ! $invoice->isPaid()) {
if ($invoice && ! $invoice->isPaid() && $account->auto_email_invoice) {
$this->info('Not billed - Sending Invoice');
$this->mailer->sendInvoice($invoice);
dispatch(new SendInvoiceEmail($invoice, $invoice->user_id));
} elseif ($invoice) {
$this->info('Successfully billed invoice');
}

View File

@ -6,9 +6,10 @@ use App\Libraries\CurlUtils;
use Carbon;
use Str;
use Cache;
use Exception;
use App\Jobs\SendInvoiceEmail;
use App\Models\Invoice;
use App\Models\Currency;
use App\Ninja\Mailers\ContactMailer as Mailer;
use App\Ninja\Mailers\UserMailer;
use App\Ninja\Repositories\AccountRepository;
use App\Ninja\Repositories\InvoiceRepository;
@ -33,11 +34,6 @@ class SendReminders extends Command
*/
protected $description = 'Send reminder emails';
/**
* @var Mailer
*/
protected $mailer;
/**
* @var InvoiceRepository
*/
@ -55,11 +51,10 @@ class SendReminders extends Command
* @param InvoiceRepository $invoiceRepo
* @param accountRepository $accountRepo
*/
public function __construct(Mailer $mailer, InvoiceRepository $invoiceRepo, AccountRepository $accountRepo, UserMailer $userMailer)
public function __construct(InvoiceRepository $invoiceRepo, AccountRepository $accountRepo, UserMailer $userMailer)
{
parent::__construct();
$this->mailer = $mailer;
$this->invoiceRepo = $invoiceRepo;
$this->accountRepo = $accountRepo;
$this->userMailer = $userMailer;
@ -136,7 +131,7 @@ class SendReminders extends Command
continue;
}
$this->info('Send email: ' . $invoice->id);
$this->mailer->sendInvoice($invoice, $reminder);
dispatch(new SendInvoiceEmail($invoice, $invoice->user_id, $reminder));
}
}
@ -149,7 +144,7 @@ class SendReminders extends Command
continue;
}
$this->info('Send email: ' . $invoice->id);
$this->mailer->sendInvoice($invoice, 'reminder4');
dispatch(new SendInvoiceEmail($invoice, $invoice->user_id, 'reminder4'));
}
}
}
@ -162,6 +157,8 @@ class SendReminders extends Command
$this->info($scheduledReports->count() . ' scheduled reports');
foreach ($scheduledReports as $scheduledReport) {
$this->info('Processing report: ' . $scheduledReport->id);
$user = $scheduledReport->user;
$account = $scheduledReport->account;
@ -179,7 +176,14 @@ class SendReminders extends Command
$file = dispatch(new ExportReportResults($scheduledReport->user, $config['export_format'], $reportType, $report->exportParams));
if ($file) {
try {
$this->userMailer->sendScheduledReport($scheduledReport, $file);
$this->info('Sent report');
} catch (Exception $exception) {
$this->info('ERROR: ' . $exception->getMessage());
}
} else {
$this->info('ERROR: Failed to run report');
}
$scheduledReport->updateSendDate();

View File

@ -1,6 +1,6 @@
<?php
Route::group(['middleware' => 'auth', 'namespace' => '$MODULE_NAMESPACE$\$STUDLY_NAME$\Http\Controllers'], function()
Route::group(['middleware' => ['web', 'lookup:user', 'auth:user'], 'namespace' => '$MODULE_NAMESPACE$\$STUDLY_NAME$\Http\Controllers'], function()
{
Route::resource('$LOWER_NAME$', '$STUDLY_NAME$Controller');
Route::post('$LOWER_NAME$/bulk', '$STUDLY_NAME$Controller@bulk');

View File

@ -301,6 +301,7 @@ if (! defined('APP_NAME')) {
define('GATEWAY_BRAINTREE', 61);
define('GATEWAY_CUSTOM', 62);
define('GATEWAY_GOCARDLESS', 64);
define('GATEWAY_PAYMILL', 66);
// The customer exists, but only as a local concept
// The remote gateway doesn't understand the concept of customers
@ -338,7 +339,8 @@ if (! defined('APP_NAME')) {
define('NINJA_APP_URL', env('NINJA_APP_URL', 'https://app.invoiceninja.com'));
define('NINJA_DOCS_URL', env('NINJA_DOCS_URL', 'http://docs.invoiceninja.com/en/latest'));
define('NINJA_DATE', '2000-01-01');
define('NINJA_VERSION', '4.2.2' . env('NINJA_VERSION_SUFFIX'));
define('NINJA_VERSION', '4.3.0' . env('NINJA_VERSION_SUFFIX'));
define('NINJA_TERMS_VERSION', '');
define('SOCIAL_LINK_FACEBOOK', env('SOCIAL_LINK_FACEBOOK', 'https://www.facebook.com/invoiceninja'));
define('SOCIAL_LINK_TWITTER', env('SOCIAL_LINK_TWITTER', 'https://twitter.com/invoiceninja'));

View File

@ -0,0 +1,29 @@
<?php
namespace App\Events;
use App\Models\Project;
use Illuminate\Queue\SerializesModels;
/**
* Class ProjectWasDeleted.
*/
class ProjectWasDeleted extends Event
{
use SerializesModels;
/**
* @var Prooject
*/
public $project;
/**
* Create a new event instance.
*
* @param Invoice $invoice
*/
public function __construct(Project $project)
{
$this->project = $project;
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Events;
use App\Models\Proposal;
use Illuminate\Queue\SerializesModels;
/**
* Class ProposalWasDeleted.
*/
class ProposalWasDeleted extends Event
{
use SerializesModels;
/**
* @var Proposal
*/
public $proposal;
/**
* Create a new event instance.
*
* @param Invoice $invoice
*/
public function __construct(Proposal $proposal)
{
$this->proposal = $proposal;
}
}

View File

@ -68,7 +68,11 @@ class Handler extends ExceptionHandler
}
// Log 404s to a separate file
$errorStr = date('Y-m-d h:i:s') . ' ' . $e->getMessage() . ' URL:' . request()->url() . "\n" . json_encode(Utils::prepareErrorData('PHP')) . "\n\n";
if (config('app.log') == 'single') {
@file_put_contents(storage_path('logs/not-found.log'), $errorStr, FILE_APPEND);
} else {
Utils::logError('[not found] ' . $errorStr);
}
return false;
} elseif ($e instanceof HttpResponseException) {
return false;
@ -77,7 +81,11 @@ class Handler extends ExceptionHandler
if (! Utils::isTravis()) {
Utils::logError(Utils::getErrorString($e));
$stacktrace = date('Y-m-d h:i:s') . ' ' . $e->getMessage() . ': ' . $e->getTraceAsString() . "\n\n";
if (config('app.log') == 'single') {
@file_put_contents(storage_path('logs/stacktrace.log'), $stacktrace, FILE_APPEND);
} else {
Utils::logError('[stacktrace] ' . $stacktrace);
}
return false;
} else {
return parent::report($e);

View File

@ -46,6 +46,7 @@ use Utils;
use Validator;
use View;
use App\Jobs\PurgeClientData;
/**
* Class AccountController.
@ -139,7 +140,12 @@ class AccountController extends BaseController
Session::flash('warning', $message);
}
$redirectTo = Input::get('redirect_to') ? SITE_URL . '/' . ltrim(Input::get('redirect_to'), '/') : 'dashboard';
if ($redirectTo = Input::get('redirect_to')) {
$redirectTo = SITE_URL . '/' . ltrim($redirectTo, '/');
} else {
$redirectTo = Input::get('sign_up') ? 'dashboard' : 'invoices/create';
}
return Redirect::to($redirectTo)->with('sign_up', Input::get('sign_up'));
}
@ -389,7 +395,9 @@ class AccountController extends BaseController
$planDetails = $account->getPlanDetails(true, false);
$portalLink = false;
if (Utils::isNinja() && $planDetails && $ninjaClient = $this->accountRepo->getNinjaClient($account)) {
if (Utils::isNinja() && $planDetails
&& $account->getPrimaryAccount()->id == auth()->user()->account_id
&& $ninjaClient = $this->accountRepo->getNinjaClient($account)) {
$contact = $ninjaClient->getPrimaryContact();
$portalLink = $contact->link;
}
@ -473,7 +481,7 @@ class AccountController extends BaseController
$trashedCount = AccountGateway::scope()->withTrashed()->count();
if ($accountGateway = $account->getGatewayConfig(GATEWAY_STRIPE)) {
if (! $accountGateway->getPublishableStripeKey()) {
if (! $accountGateway->getPublishableKey()) {
Session::now('warning', trans('texts.missing_publishable_key'));
}
}
@ -976,6 +984,9 @@ class AccountController extends BaseController
$account->invoice_footer = Input::get('invoice_footer');
$account->quote_terms = Input::get('quote_terms');
$account->auto_convert_quote = Input::get('auto_convert_quote');
$account->auto_archive_quote = Input::get('auto_archive_quote');
$account->auto_archive_invoice = Input::get('auto_archive_invoice');
$account->auto_email_invoice = Input::get('auto_email_invoice');
$account->recurring_invoice_number_prefix = Input::get('recurring_invoice_number_prefix');
$account->client_number_prefix = trim(Input::get('client_number_prefix'));
@ -1067,6 +1078,7 @@ class AccountController extends BaseController
$user->notify_viewed = Input::get('notify_viewed');
$user->notify_paid = Input::get('notify_paid');
$user->notify_approved = Input::get('notify_approved');
$user->slack_webhook_url = Input::get('slack_webhook_url');
$user->save();
$account = $user->account;
@ -1265,6 +1277,7 @@ class AccountController extends BaseController
$account->token_billing_type_id = Input::get('token_billing_type_id');
$account->auto_bill_on_due_date = boolval(Input::get('auto_bill_on_due_date'));
$account->gateway_fee_enabled = boolval(Input::get('gateway_fee_enabled'));
$account->send_item_details = boolval(Input::get('send_item_details'));
$account->save();
@ -1326,6 +1339,7 @@ class AccountController extends BaseController
public function submitSignup()
{
$user = Auth::user();
$ip = Request::getClientIp();
$account = $user->account;
$rules = [
@ -1357,6 +1371,7 @@ class AccountController extends BaseController
if ($user->registered) {
$newAccount = $this->accountRepo->create($firstName, $lastName, $email, $password, $account->company);
$newUser = $newAccount->users()->first();
$newUser->acceptLatestTerms($ip)->save();
$users = $this->accountRepo->associateAccounts($user->id, $newUser->id);
Session::flash('message', trans('texts.created_new_company'));
@ -1371,12 +1386,13 @@ class AccountController extends BaseController
$user->username = $user->email;
$user->password = bcrypt($password);
$user->registered = true;
$user->acceptLatestTerms($ip);
$user->save();
$user->account->startTrial(PLAN_PRO);
if (Input::get('go_pro') == 'true') {
Session::set(REQUESTED_PRO_PLAN, true);
session([REQUESTED_PRO_PLAN => true]);
}
return "{$user->first_name} {$user->last_name}";
@ -1452,7 +1468,7 @@ class AccountController extends BaseController
$refunded = $company->processRefund(Auth::user());
$ninjaClient = $this->accountRepo->getNinjaClient($account);
$ninjaClient->delete();
dispatch(new \App\Jobs\PurgeClientData($ninjaClient));
}
Document::scope()->each(function ($item, $key) {

View File

@ -183,6 +183,8 @@ class AccountGatewayController extends BaseController
if ($gatewayId == GATEWAY_DWOLLA) {
$optional = array_merge($optional, ['key', 'secret']);
} elseif ($gatewayId == GATEWAY_PAYMILL) {
$rules['publishable_key'] = 'required';
} elseif ($gatewayId == GATEWAY_STRIPE) {
if (Utils::isNinjaDev()) {
// do nothing - we're unable to acceptance test with StripeJS

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Events\UserSettingsChanged;
use App\Models\Account;
use App\Models\Industry;
use App\Models\Invoice;
use App\Ninja\Mailers\Mailer;
use App\Ninja\Repositories\AccountRepository;
use App\Services\EmailService;
@ -425,4 +426,42 @@ class AppController extends BaseController
return json_encode($data);
}
public function testHeadless()
{
$invoice = Invoice::scope()->orderBy('id')->first();
if (! $invoice) {
dd('Please create an invoice to run this test');
}
header('Content-type:application/pdf');
echo $invoice->getPDFString();
exit;
}
public function runCommand()
{
if (Utils::isNinjaProd()) {
abort(400, 'Not allowed');
}
$command = request()->command;
$options = request()->options ?: [];
$secret = env('COMMAND_SECRET');
if (! $secret) {
exit('Set a value for COMMAND_SECRET in the .env file');
} elseif (! hash_equals($secret, request()->secret ?: '')) {
exit('Invalid secret');
}
if (! $command || ! in_array($command, ['send-invoices', 'send-reminders', 'update-key'])) {
exit('Invalid command: Valid options are send-invoices, send-reminders or update-key');
}
Artisan::call('ninja:' . $command, $options);
return response(nl2br(Artisan::output()));
}
}

View File

@ -105,8 +105,11 @@ class LoginController extends Controller
*/
} else {
$stacktrace = sprintf("%s %s %s %s\n", date('Y-m-d h:i:s'), $request->input('email'), \Request::getClientIp(), array_get($_SERVER, 'HTTP_USER_AGENT'));
if (config('app.log') == 'single') {
file_put_contents(storage_path('logs/failed-logins.log'), $stacktrace, FILE_APPEND);
error_log('login failed');
} else {
Utils::logError('[failed login] ' . $stacktrace);
}
if ($user) {
$user->failed_logins = $user->failed_logins + 1;
$user->save();

View File

@ -50,4 +50,21 @@ class BaseController extends Controller
return redirect("{$entityTypes}");
}
}
protected function downloadResponse($filename, $contents, $type = 'application/pdf')
{
header('Content-Type: ' . $type);
header('Content-Length: ' . strlen($contents));
if (! request()->debug) {
header('Content-disposition: attachment; filename="' . $filename . '"');
}
header('Cache-Control: public, must-revalidate, max-age=0');
header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT');
echo $contents;
exit;
}
}

View File

@ -5,10 +5,13 @@ namespace App\Http\Controllers;
use App\Http\Requests\ClientRequest;
use App\Http\Requests\CreateClientRequest;
use App\Http\Requests\UpdateClientRequest;
use App\Jobs\LoadPostmarkHistory;
use App\Jobs\ReactivatePostmarkEmail;
use App\Models\Account;
use App\Models\Client;
use App\Models\Credit;
use App\Models\Invoice;
use App\Models\Expense;
use App\Models\Task;
use App\Ninja\Datatables\ClientDatatable;
use App\Ninja\Repositories\ClientRepository;
@ -84,6 +87,7 @@ class ClientController extends BaseController
{
$client = $request->entity();
$user = Auth::user();
$account = $user->account;
$actionLinks = [];
if ($user->can('create', ENTITY_INVOICE)) {
@ -118,14 +122,16 @@ class ClientController extends BaseController
$token = $client->getGatewayToken();
$data = [
'account' => $account,
'actionLinks' => $actionLinks,
'showBreadcrumbs' => false,
'client' => $client,
'credit' => $client->getTotalCredit(),
'title' => trans('texts.view_client'),
'hasRecurringInvoices' => Invoice::scope()->recurring()->withArchived()->whereClientId($client->id)->count() > 0,
'hasQuotes' => Invoice::scope()->quotes()->withArchived()->whereClientId($client->id)->count() > 0,
'hasTasks' => Task::scope()->withArchived()->whereClientId($client->id)->count() > 0,
'hasRecurringInvoices' => $account->isModuleEnabled(ENTITY_RECURRING_INVOICE) && Invoice::scope()->recurring()->withArchived()->whereClientId($client->id)->count() > 0,
'hasQuotes' => $account->isModuleEnabled(ENTITY_QUOTE) && Invoice::scope()->quotes()->withArchived()->whereClientId($client->id)->count() > 0,
'hasTasks' => $account->isModuleEnabled(ENTITY_TASK) && Task::scope()->withArchived()->whereClientId($client->id)->count() > 0,
'hasExpenses' => $account->isModuleEnabled(ENTITY_EXPENSE) && Expense::scope()->withArchived()->whereClientId($client->id)->count() > 0,
'gatewayLink' => $token ? $token->gatewayLink() : false,
'gatewayName' => $token ? $token->gatewayName() : false,
];
@ -216,13 +222,22 @@ class ClientController extends BaseController
{
$action = Input::get('action');
$ids = Input::get('public_id') ? Input::get('public_id') : Input::get('ids');
if ($action == 'purge' && ! auth()->user()->is_admin) {
return redirect('dashboard')->withError(trans('texts.not_authorized'));
}
$count = $this->clientService->bulk($ids, $action);
$message = Utils::pluralize($action.'d_client', $count);
Session::flash('message', $message);
if ($action == 'purge') {
return redirect('dashboard')->withMessage($message);
} else {
return $this->returnBulk(ENTITY_CLIENT, $action, $ids);
}
}
public function statement($clientPublicId, $statusId = false, $startDate = false, $endDate = false)
{
@ -273,4 +288,18 @@ class ClientController extends BaseController
return view('clients.statement', $data);
}
public function getEmailHistory()
{
$history = dispatch(new LoadPostmarkHistory(request()->email));
return response()->json($history);
}
public function reactivateEmail()
{
$result = dispatch(new ReactivatePostmarkEmail(request()->bounce_id));
return response()->json($result);
}
}

View File

@ -113,6 +113,7 @@ class ClientPortalController extends BaseController
'custom_value1',
'custom_value2',
]);
$account->load(['date_format', 'datetime_format']);
// translate the country names
if ($invoice->client->country) {
@ -987,7 +988,7 @@ class ClientPortalController extends BaseController
'email' => 'required',
'address1' => 'required',
'city' => 'required',
'state' => 'required',
'state' => $account->requiresAddressState() ? 'required' : '',
'postal_code' => 'required',
'country_id' => 'required',
];

View File

@ -2,11 +2,11 @@
namespace App\Http\Controllers;
use mPDF;
use App\Models\Account;
use App\Models\Document;
use App\Models\Invitation;
use App\Ninja\Repositories\ProposalRepository;
use App\Jobs\ConvertProposalToPdf;
class ClientPortalProposalController extends BaseController
{
@ -39,8 +39,12 @@ class ClientPortalProposalController extends BaseController
'proposalInvitation' => $invitation,
];
if (request()->phantomjs) {
return $proposal->present()->htmlDocument;
} else {
return view('invited.proposal', $data);
}
}
public function downloadProposal($invitationKey)
{
@ -50,9 +54,9 @@ class ClientPortalProposalController extends BaseController
$proposal = $invitation->proposal;
$mpdf = new mPDF();
$mpdf->WriteHTML($proposal->present()->htmlDocument);
$mpdf->Output($proposal->present()->filename, 'D');
$pdf = dispatch(new ConvertProposalToPdf($proposal));
$this->downloadResponse($proposal->getFilename(), $pdf);
}
public function getProposalImage($accountKey, $documentKey)

View File

@ -18,6 +18,7 @@ use Auth;
use Cache;
use Input;
use Redirect;
use Request;
use Session;
use URL;
use Utils;
@ -82,9 +83,7 @@ class ExpenseController extends BaseController
'method' => 'POST',
'url' => 'expenses',
'title' => trans('texts.new_expense'),
'vendors' => Vendor::scope()->with('vendor_contacts')->orderBy('name')->get(),
'vendor' => $vendor,
'clients' => Client::scope()->with('contacts')->orderBy('name')->get(),
'clientPublicId' => $request->client_id,
'categoryPublicId' => $request->category_id,
];
@ -160,14 +159,12 @@ class ExpenseController extends BaseController
'url' => $url,
'title' => 'Edit Expense',
'actions' => $actions,
'vendors' => Vendor::scope()->with('vendor_contacts')->orderBy('name')->get(),
'vendorPublicId' => $expense->vendor ? $expense->vendor->public_id : null,
'clients' => Client::scope()->with('contacts')->orderBy('name')->get(),
'clientPublicId' => $expense->client ? $expense->client->public_id : null,
'categoryPublicId' => $expense->expense_category ? $expense->expense_category->public_id : null,
];
$data = array_merge($data, self::getViewModel());
$data = array_merge($data, self::getViewModel($expense));
return View::make('expenses.edit', $data);
}
@ -227,6 +224,7 @@ class ExpenseController extends BaseController
{
$action = Input::get('action');
$ids = Input::get('public_id') ? Input::get('public_id') : Input::get('ids');
$referer = Request::server('HTTP_REFERER');
switch ($action) {
case 'invoice':
@ -238,27 +236,25 @@ class ExpenseController extends BaseController
// Validate that either all expenses do not have a client or if there is a client, it is the same client
foreach ($expenses as $expense) {
if ($expense->client) {
if ($expense->client->trashed()) {
return redirect($referer)->withError(trans('texts.client_must_be_active'));
}
if (! $clientPublicId) {
$clientPublicId = $expense->client->public_id;
} elseif ($clientPublicId != $expense->client->public_id) {
Session::flash('error', trans('texts.expense_error_multiple_clients'));
return Redirect::to('expenses');
return redirect($referer)->withError(trans('texts.expense_error_multiple_clients'));
}
}
if (! $currencyId) {
$currencyId = $expense->invoice_currency_id;
} elseif ($currencyId != $expense->invoice_currency_id && $expense->invoice_currency_id) {
Session::flash('error', trans('texts.expense_error_multiple_currencies'));
return Redirect::to('expenses');
return redirect($referer)->withError(trans('texts.expense_error_multiple_currencies'));
}
if ($expense->invoice_id) {
Session::flash('error', trans('texts.expense_error_invoiced'));
return Redirect::to('expenses');
return redirect($referer)->withError(trans('texts.expense_error_invoiced'));
}
}
@ -287,12 +283,14 @@ class ExpenseController extends BaseController
return $this->returnBulk($this->entityType, $action, $ids);
}
private static function getViewModel()
private static function getViewModel($expense = false)
{
return [
'data' => Input::old('data'),
'account' => Auth::user()->account,
'categories' => ExpenseCategory::whereAccountId(Auth::user()->account_id)->withArchived()->orderBy('name')->get(),
'vendors' => Vendor::scope()->withActiveOrSelected($expense ? $expense->vendor_id : false)->with('vendor_contacts')->orderBy('name')->get(),
'clients' => Client::scope()->withActiveOrSelected($expense ? $expense->client_id : false)->with('contacts')->orderBy('name')->get(),
'categories' => ExpenseCategory::whereAccountId(Auth::user()->account_id)->withActiveOrSelected($expense ? $expense->expense_category_id : false)->orderBy('name')->get(),
'taxRates' => TaxRate::scope()->whereIsInclusive(false)->orderBy('name')->get(),
'isRecurring' => false,
];

View File

@ -67,7 +67,7 @@ class HomeController extends BaseController
{
// Track the referral/campaign code
if (Input::has('rc')) {
Session::set(SESSION_REFERRAL_CODE, Input::get('rc'));
session([SESSION_REFERRAL_CODE => Input::get('rc')]);
}
if (Auth::check()) {

View File

@ -327,7 +327,7 @@ class InvoiceController extends BaseController
'invoiceLabels' => Auth::user()->account->getInvoiceLabels(),
'tasks' => Session::get('tasks') ? Session::get('tasks') : null,
'expenseCurrencyId' => Session::get('expenseCurrencyId') ?: null,
'expenses' => Session::get('expenses') ? Expense::scope(Session::get('expenses'))->with('documents', 'expense_category')->get() : [],
'expenses' => Expense::scope(Session::get('expenses'))->with('documents', 'expense_category')->get(),
];
}

View File

@ -92,19 +92,19 @@ class NinjaController extends BaseController
public function show_license_payment()
{
if (Input::has('return_url')) {
Session::set('return_url', Input::get('return_url'));
session(['return_url' => Input::get('return_url')]);
}
if (Input::has('affiliate_key')) {
if ($affiliate = Affiliate::where('affiliate_key', '=', Input::get('affiliate_key'))->first()) {
Session::set('affiliate_id', $affiliate->id);
session(['affiliate_id' => $affiliate->id]);
}
}
if (Input::has('product_id')) {
Session::set('product_id', Input::get('product_id'));
session(['product_id' => Input::get('product_id')]);
} elseif (! Session::has('product_id')) {
Session::set('product_id', PRODUCT_ONE_CLICK_INSTALL);
session(['product_id' => PRODUCT_ONE_CLICK_INSTALL]);
}
if (! Session::get('affiliate_id')) {
@ -112,7 +112,7 @@ class NinjaController extends BaseController
}
if (Utils::isNinjaDev() && Input::has('test_mode')) {
Session::set('test_mode', Input::get('test_mode'));
session(['test_mode' => Input::get('test_mode')]);
}
$account = $this->accountRepo->getNinjaAccount();

View File

@ -75,14 +75,14 @@ class OnlinePaymentController extends BaseController
]);
}
if (! $invitation->invoice->canBePaid() && ! request()->update) {
if (! request()->capture && ! $invitation->invoice->canBePaid()) {
return redirect()->to('view/' . $invitation->invitation_key);
}
$invitation = $invitation->load('invoice.client.account.account_gateways.gateway');
$account = $invitation->account;
if ($account->requiresAuthorization($invitation->invoice) && ! session('authorized:' . $invitation->invitation_key) && ! request()->update) {
if (! request()->capture && $account->requiresAuthorization($invitation->invoice) && ! session('authorized:' . $invitation->invitation_key)) {
return redirect()->to('view/' . $invitation->invitation_key);
}
@ -126,14 +126,14 @@ class OnlinePaymentController extends BaseController
$paymentDriver = $invitation->account->paymentDriver($invitation, $gatewayTypeId);
if (! $invitation->invoice->canBePaid() && ! request()->update) {
if (! $invitation->invoice->canBePaid() && ! request()->capture) {
return redirect()->to('view/' . $invitation->invitation_key);
}
try {
$paymentDriver->completeOnsitePurchase($request->all());
if (request()->update) {
if (request()->capture) {
return redirect('/client/dashboard')->withMessage(trans('texts.updated_payment_details'));
} elseif ($paymentDriver->isTwoStep()) {
Session::flash('warning', trans('texts.bank_account_verification_next_steps'));

View File

@ -2,7 +2,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\CreatePaymentTermAPIRequest;
use App\Http\Requests\CreatePaymentTermRequest;
use App\Http\Requests\PaymentTermRequest;
use App\Http\Requests\UpdatePaymentTermRequest;
use App\Libraries\Utils;
@ -39,7 +39,7 @@ class PaymentTermApiController extends BaseAPIController
* @SWG\Response(
* response=200,
* description="A list of payment terms",
* @SWG\Schema(type="array", @SWG\Items(ref="#/definitions/PaymentTerms"))
* @SWG\Schema(type="array", @SWG\Items(ref="#/definitions/PaymentTerm"))
* ),
* @SWG\Response(
* response="default",
@ -73,7 +73,7 @@ class PaymentTermApiController extends BaseAPIController
* @SWG\Response(
* response=200,
* description="A single payment term",
* @SWG\Schema(type="object", @SWG\Items(ref="#/definitions/PaymentTerms"))
* @SWG\Schema(type="object", @SWG\Items(ref="#/definitions/PaymentTerm"))
* ),
* @SWG\Response(
* response="default",
@ -110,7 +110,7 @@ class PaymentTermApiController extends BaseAPIController
* )
* )
*/
public function store(CreatePaymentTermAPIRequest $request)
public function store(CreatePaymentTermRequest $request)
{
$paymentTerm = PaymentTerm::createNew();

View File

@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Http\Requests\CreatePaymentTermRequest;
use App\Http\Requests\UpdatePaymentTermRequest;
use App\Models\PaymentTerm;
use App\Services\PaymentTermService;
use Auth;
@ -84,7 +86,7 @@ class PaymentTermController extends BaseController
/**
* @return \Illuminate\Http\RedirectResponse
*/
public function store()
public function store(CreatePaymentTermRequest $request)
{
return $this->save();
}
@ -94,7 +96,7 @@ class PaymentTermController extends BaseController
*
* @return \Illuminate\Http\RedirectResponse
*/
public function update($publicId)
public function update(UpdatePaymentTermRequest $request, $publicId)
{
return $this->save($publicId);
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Http\Requests\ProductRequest;
use App\Models\Product;
use App\Models\TaxRate;
use App\Ninja\Datatables\ProductDatatable;
@ -71,23 +72,39 @@ class ProductController extends BaseController
return $this->productService->getDatatable(Auth::user()->account_id, Input::get('sSearch'));
}
public function cloneProduct(ProductRequest $request, $publicId)
{
return self::edit($request, $publicId, true);
}
/**
* @param $publicId
*
* @return \Illuminate\Contracts\View\View
*/
public function edit($publicId)
public function edit(ProductRequest $request, $publicId, $clone = false)
{
$account = Auth::user()->account;
$product = Product::scope($publicId)->withTrashed()->firstOrFail();
if ($clone) {
$product->id = null;
$product->public_id = null;
$product->deleted_at = null;
$url = 'products';
$method = 'POST';
} else {
$url = 'products/'.$publicId;
$method = 'PUT';
}
$data = [
'account' => $account,
'taxRates' => $account->invoice_item_taxes ? TaxRate::scope()->whereIsInclusive(false)->get() : null,
'product' => $product,
'entity' => $product,
'method' => 'PUT',
'url' => 'products/'.$publicId,
'method' => $method,
'url' => $url,
'title' => trans('texts.edit_product'),
];
@ -149,11 +166,16 @@ class ProductController extends BaseController
$message = $productPublicId ? trans('texts.updated_product') : trans('texts.created_product');
Session::flash('message', $message);
if (in_array(request('action'), ['archive', 'delete', 'restore', 'invoice'])) {
$action = request('action');
if (in_array($action, ['archive', 'delete', 'restore', 'invoice'])) {
return self::bulk();
}
return Redirect::to("products/{$product->public_id}/edit");
if ($action == 'clone') {
return redirect()->to(sprintf('products/%s/clone', $product->public_id));
} else {
return redirect()->to("products/{$product->public_id}/edit");
}
}
/**

View File

@ -6,6 +6,7 @@ use App\Http\Requests\CreateProposalRequest;
use App\Http\Requests\ProposalRequest;
use App\Http\Requests\UpdateProposalRequest;
use App\Jobs\SendInvoiceEmail;
use App\Jobs\ConvertProposalToPdf;
use App\Models\Invoice;
use App\Models\Proposal;
use App\Models\ProposalTemplate;
@ -17,7 +18,6 @@ use Auth;
use Input;
use Session;
use View;
use mPDF;
class ProposalController extends BaseController
{
@ -82,13 +82,13 @@ class ProposalController extends BaseController
{
$proposal = $request->entity();
$data = array_merge($this->getViewmodel(), [
$data = array_merge($this->getViewmodel($proposal), [
'proposal' => $proposal,
'entity' => $proposal,
'method' => 'PUT',
'url' => 'proposals/' . $proposal->public_id,
'title' => trans('texts.edit_proposal'),
'invoices' => Invoice::scope()->with('client.contacts', 'client.country')->unapprovedQuotes($proposal->invoice_id)->orderBy('id')->get(),
'invoices' => Invoice::scope()->with('client.contacts', 'client.country')->withActiveOrSelected($proposal->invoice_id)->unapprovedQuotes($proposal->invoice_id)->orderBy('id')->get(),
'invoicePublicId' => $proposal->invoice ? $proposal->invoice->public_id : null,
'templatePublicId' => $proposal->proposal_template ? $proposal->proposal_template->public_id : null,
]);
@ -96,10 +96,10 @@ class ProposalController extends BaseController
return View::make('proposals.edit', $data);
}
private function getViewmodel()
private function getViewmodel($proposal = false)
{
$account = auth()->user()->account;
$templates = ProposalTemplate::whereAccountId($account->id)->orderBy('name')->get();
$templates = ProposalTemplate::whereAccountId($account->id)->withActiveOrSelected($proposal ? $proposal->proposal_template_id : false)->orderBy('name')->get();
if (! $templates->count()) {
$templates = ProposalTemplate::whereNull('account_id')->orderBy('name')->get();
@ -167,12 +167,8 @@ class ProposalController extends BaseController
{
$proposal = $request->entity();
$mpdf = new mPDF();
$mpdf->showImageErrors = true;
$mpdf->WriteHTML($proposal->present()->htmlDocument);
$pdf = dispatch(new ConvertProposalToPdf($proposal));
//$mpdf->Output();
$mpdf->Output($proposal->present()->filename, 'D');
$this->downloadResponse($proposal->getFilename(), $pdf);
}
}

View File

@ -84,7 +84,7 @@ class ProposalSnippetController extends BaseController
'method' => 'PUT',
'url' => 'proposals/snippets/' . $proposalSnippet->public_id,
'title' => trans('texts.edit_proposal_snippet'),
'categories' => ProposalCategory::scope()->orderBy('name')->get(),
'categories' => ProposalCategory::scope()->withActiveOrSelected($proposalSnippet->proposal_category_id)->orderBy('name')->get(),
'categoryPublicId' => $proposalSnippet->proposal_category ? $proposalSnippet->proposal_category->public_id : null,
'icons' => $this->getIcons(),
];

View File

@ -108,7 +108,7 @@ class QuoteController extends BaseController
'invoiceFonts' => Cache::get('fonts'),
'invoiceLabels' => Auth::user()->account->getInvoiceLabels(),
'isRecurring' => false,
'expenses' => [],
'expenses' => collect(),
];
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Jobs\ExportReportResults;
use App\Jobs\LoadPostmarkStats;
use App\Jobs\RunReport;
use App\Models\Account;
use App\Models\ScheduledReport;
@ -13,6 +14,7 @@ use View;
use Carbon;
use Validator;
/**
* Class ReportController.
*/
@ -101,7 +103,8 @@ class ReportController extends BaseController
$config = [
'date_field' => $dateField,
'status_ids' => request()->status_ids,
'group_dates_by' => request()->group_dates_by,
'group' => request()->group,
'subgroup' => request()->subgroup,
'document_filter' => request()->document_filter,
'currency_type' => request()->currency_type,
'export_format' => $format,
@ -152,7 +155,8 @@ class ReportController extends BaseController
unset($options['start_date']);
unset($options['end_date']);
unset($options['group_dates_by']);
unset($options['group']);
unset($options['subgroup']);
$schedule = ScheduledReport::createNew();
$schedule->config = json_encode($options);
@ -173,4 +177,20 @@ class ReportController extends BaseController
session()->flash('message', trans('texts.deleted_scheduled_report'));
}
public function showEmailReport()
{
$data = [
'account' => auth()->user()->account,
];
return view('reports.emails', $data);
}
public function loadEmailReport($startDate, $endDate)
{
$data = dispatch(new LoadPostmarkStats($startDate, $endDate));
return response()->json($data);
}
}

View File

@ -16,6 +16,7 @@ use Auth;
use DropdownButton;
use Input;
use Redirect;
use Request;
use Session;
use URL;
use Utils;
@ -189,7 +190,7 @@ class TaskController extends BaseController
'datetimeFormat' => Auth::user()->account->getMomentDateTimeFormat(),
];
$data = array_merge($data, self::getViewModel());
$data = array_merge($data, self::getViewModel($task));
return View::make('tasks.edit', $data);
}
@ -211,12 +212,12 @@ class TaskController extends BaseController
/**
* @return array
*/
private static function getViewModel()
private static function getViewModel($task = false)
{
return [
'clients' => Client::scope()->with('contacts')->orderBy('name')->get(),
'clients' => Client::scope()->withActiveOrSelected($task ? $task->client_id : false)->with('contacts')->orderBy('name')->get(),
'account' => Auth::user()->account,
'projects' => Project::scope()->with('client.contacts')->orderBy('name')->get(),
'projects' => Project::scope()->withActiveOrSelected($task ? $task->project_id : false)->with('client.contacts')->orderBy('name')->get(),
];
}
@ -260,6 +261,7 @@ class TaskController extends BaseController
{
$action = Input::get('action');
$ids = Input::get('public_id') ?: (Input::get('id') ?: Input::get('ids'));
$referer = Request::server('HTTP_REFERER');
if (in_array($action, ['resume', 'stop'])) {
$this->taskRepo->save($ids, ['action' => $action]);
@ -273,23 +275,21 @@ class TaskController extends BaseController
$lastProjectId = false;
foreach ($tasks as $task) {
if ($task->client) {
if ($task->client->trashed()) {
return redirect($referer)->withError(trans('texts.client_must_be_active'));
}
if (! $clientPublicId) {
$clientPublicId = $task->client->public_id;
} elseif ($clientPublicId != $task->client->public_id) {
Session::flash('error', trans('texts.task_error_multiple_clients'));
return Redirect::to('tasks');
return redirect($referer)->withError(trans('texts.task_error_multiple_clients'));
}
}
if ($task->is_running) {
Session::flash('error', trans('texts.task_error_running'));
return Redirect::to('tasks');
return redirect($referer)->withError(trans('texts.task_error_running'));
} elseif ($task->invoice_id) {
Session::flash('error', trans('texts.task_error_invoiced'));
return Redirect::to('tasks');
return redirect($referer)->withError(trans('texts.task_error_invoiced'));
}
$account = Auth::user()->account;

View File

@ -45,15 +45,6 @@ class UserController extends BaseController
return $this->userService->getDatatable(Auth::user()->account_id);
}
public function setTheme()
{
$user = User::find(Auth::user()->id);
$user->theme_id = Input::get('theme_id');
$user->save();
return Redirect::to(Input::get('path'));
}
public function forcePDFJS()
{
$user = Auth::user();
@ -401,4 +392,18 @@ class UserController extends BaseController
return RESULT_SUCCESS;
}
public function acceptTerms()
{
$ip = Request::getClientIp();
$referer = Request::server('HTTP_REFERER');
$message = '';
if (request()->accepted_terms) {
auth()->user()->acceptLatestTerms($ip)->save();
$message = trans('texts.accepted_terms');
}
return redirect($referer)->withMessage($message);
}
}

View File

@ -30,11 +30,13 @@ class Kernel extends HttpKernel
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
//\Illuminate\Routing\Middleware\SubstituteBindings::class,
\Illuminate\Foundation\Http\Middleware\TrimStrings::class,
\App\Http\Middleware\DuplicateSubmissionCheck::class,
\App\Http\Middleware\QueryLogging::class,
\App\Http\Middleware\StartupCheck::class,
],
'api' => [
\App\Http\Middleware\QueryLogging::class,
\App\Http\Middleware\ApiCheck::class,
],
/*

View File

@ -54,7 +54,7 @@ class ApiCheck
// check if user is archived
if ($token && $token->user) {
Auth::onceUsingId($token->user_id);
Session::set('token_id', $token->id);
session(['token_id' => $token->id]);
} elseif ($hasApiSecret && $request->is('api/v1/ping')) {
// do nothing: allow ping with api_secret or account token
} else {

View File

@ -147,7 +147,7 @@ class StartupCheck
if (Input::has('lang')) {
$locale = Input::get('lang');
App::setLocale($locale);
Session::set(SESSION_LOCALE, $locale);
session([SESSION_LOCALE => $locale]);
if (Auth::check()) {
if ($language = Language::whereLocale($locale)->first()) {

View File

@ -4,7 +4,7 @@ namespace App\Http\Requests;
use App\Models\Invoice;
class CreatePaymentTermAPIRequest extends Request
class CreatePaymentTermRequest extends PaymentTermRequest
{
/**
* Determine if the user is authorized to make this request.
@ -27,7 +27,8 @@ class CreatePaymentTermAPIRequest extends Request
{
$rules = [
'num_days' => 'required|numeric|unique:payment_terms',
'num_days' => 'required|numeric|unique:payment_terms,num_days,,id,account_id,' . $this->user()->account_id . ',deleted_at,NULL'
. '|unique:payment_terms,num_days,,id,account_id,0,deleted_at,NULL',
];

View File

@ -2,16 +2,41 @@
namespace App\Http\Requests;
class UpdatePaymentTermRequest extends EntityRequest
use App\Models\Invoice;
class UpdatePaymentTermRequest extends PaymentTermRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return $this->entity() && $this->user()->can('edit', $this->entity());
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
if (! $this->entity()) {
return [];
}
$paymentTermId = $this->entity()->id;
$rules = [
'num_days' => 'required|numeric|unique:payment_terms,num_days,' . $paymentTermId . ',id,account_id,' . $this->user()->account_id . ',deleted_at,NULL'
. '|unique:payment_terms,num_days,' . $paymentTermId . ',id,account_id,0,deleted_at,NULL',
];
return $rules;
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Jobs;
use App\Jobs\Job;
use App\Libraries\CurlUtils;
class ConvertProposalToPdf extends Job
{
public function __construct($proposal)
{
$this->proposal = $proposal;
}
public function handle()
{
$proposal = $this->proposal;
$url = $proposal->getHeadlessLink();
$filename = sprintf('%s/storage/app/%s.pdf', base_path(), strtolower(str_random(RANDOM_KEY_LENGTH)));
$pdf = CurlUtils::renderPDF($url, $filename);
return $pdf;
}
}

View File

@ -66,8 +66,12 @@ class ExportReportResults extends Job
foreach ($each as $dimension => $val) {
$tmp = [];
$tmp[] = Utils::getFromCache($currencyId, 'currencies')->name . (($dimension) ? ' - ' . $dimension : '');
foreach ($val as $id => $field) {
$tmp[] = Utils::formatMoney($field, $currencyId);
foreach ($val as $field => $value) {
if ($field == 'duration') {
$tmp[] = Utils::formatTime($value);
} else {
$tmp[] = Utils::formatMoney($value, $currencyId);
}
}
$summary[] = $tmp;
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Jobs;
use App\Jobs\Job;
use Postmark\PostmarkClient;
use stdClass;
class LoadPostmarkHistory extends Job
{
public function __construct($email)
{
$this->email = $email;
$this->bounceId = false;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$str = '';
if (config('services.postmark')) {
$this->account = auth()->user()->account;
$this->postmark = new PostmarkClient(config('services.postmark'));
$str .= $this->loadBounceEvents();
$str .= $this->loadEmailEvents();
}
if (! $str) {
$str = trans('texts.no_messages_found');
}
$response = new stdClass;
$response->str = $str;
$response->bounce_id = $this->bounceId;
return $response;
}
private function loadBounceEvents() {
$str = '';
$response = $this->postmark->getBounces(5, 0, null, null, $this->email, $this->account->account_key);
foreach ($response['bounces'] as $bounce) {
if (! $bounce['inactive'] || ! $bounce['canactivate']) {
continue;
}
$str .= sprintf('<b>%s</b><br/>', $bounce['subject']);
$str .= sprintf('<span class="text-danger">%s</span> | %s<br/>', $bounce['type'], $this->account->getDateTime($bounce['bouncedat'], true));
$str .= sprintf('<span class="text-muted">%s %s</span><p/>', $bounce['description'], $bounce['details']);
$this->bounceId = $bounce['id'];
}
return $str;
}
private function loadEmailEvents() {
$str = '';
$response = $this->postmark->getOutboundMessages(5, 0, $this->email, null, $this->account->account_key);
foreach ($response['messages'] as $message) {
$details = $this->postmark->getOutboundMessageDetails($message['MessageID']);
$str .= sprintf('<b>%s</b><br/>', $details['subject']);
if (count($details['messageevents'])) {
$event = $details['messageevents'][0];
$str .= sprintf('%s | %s<br/>', $event['Type'], $this->account->getDateTime($event['ReceivedAt'], true));
if ($message = $event['Details']['DeliveryMessage']) {
$str .= sprintf('<span class="text-muted">%s</span><br/>', $message);
}
if ($server = $event['Details']['DestinationServer']) {
$str .= sprintf('<span class="text-muted">%s</span><br/>', $server);
}
} else {
$str .= trans('texts.processing') . '...';
}
$str .= '<p/>';
}
}
}

View File

@ -0,0 +1,157 @@
<?php
namespace App\Jobs;
use App\Jobs\Job;
use Postmark\PostmarkClient;
use stdClass;
use DateInterval;
use DatePeriod;
class LoadPostmarkStats extends Job
{
public function __construct($startDate, $endDate)
{
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->response = new stdClass();
$this->postmark = new \Postmark\PostmarkClient(config('services.postmark'));
$this->account = auth()->user()->account;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if (! auth()->user()->hasPermission('view_all')) {
return $this->response;
}
$this->loadOverallStats();
$this->loadSentStats();
$this->loadPlatformStats();
$this->loadEmailClientStats();
return $this->response;
}
private function loadOverallStats() {
$startDate = date_create($this->startDate);
$endDate = date_create($this->endDate);
$eventTypes = ['sent', 'opened'];
foreach ($eventTypes as $eventType) {
$data = [];
$endDate->modify('+1 day');
$interval = new DateInterval('P1D');
$period = new DatePeriod($startDate, $interval, $endDate);
$endDate->modify('-1 day');
$records = [];
if ($eventType == 'sent') {
$response = $this->postmark->getOutboundSendStatistics($this->account->account_key, request()->start_date, request()->end_date);
} else {
$response = $this->postmark->getOutboundOpenStatistics($this->account->account_key, request()->start_date, request()->end_date);
}
foreach ($response->days as $key => $val) {
$field = $eventType == 'opened' ? 'unique' : $eventType;
$data[$val['date']] = $val[$field];
}
foreach ($period as $day) {
$date = $day->format('Y-m-d');
$records[] = isset($data[$date]) ? $data[$date] : 0;
if ($eventType == 'sent') {
$labels[] = $day->format('m/d/Y');
}
}
if ($eventType == 'sent') {
$color = '51,122,183';
} elseif ($eventType == 'opened') {
$color = '54,193,87';
} elseif ($eventType == 'bounced') {
$color = '128,128,128';
}
$group = new stdClass();
$group->data = $records;
$group->label = trans("texts.{$eventType}");
$group->lineTension = 0;
$group->borderWidth = 4;
$group->borderColor = "rgba({$color}, 1)";
$group->backgroundColor = "rgba({$color}, 0.1)";
$datasets[] = $group;
}
$data = new stdClass();
$data->labels = $labels;
$data->datasets = $datasets;
$this->response->data = $data;
}
private function loadSentStats() {
$account = $this->account;
$data = $this->postmark->getOutboundOverviewStatistics($this->account->account_key, request()->start_date, request()->end_date);
$percent = $data->sent ? ($data->uniqueopens / $data->sent * 100) : 0;
$this->response->totals = [
'sent' => $account->formatNumber($data->sent),
'opened' => sprintf('%s | %s%%', $account->formatNumber($data->uniqueopens), $account->formatNumber($percent)),
'bounced' => sprintf('%s | %s%%', $account->formatNumber($data->bounced), $account->formatNumber($data->bouncerate, 3)),
//'spam' => sprintf('%s | %s%%', $account->formatNumber($data->spamcomplaints), $account->formatNumber($data->spamcomplaintsrate, 3))
];
}
private function loadPlatformStats() {
$data = $this->postmark->getOutboundPlatformStatistics($this->account->account_key, request()->start_date, request()->end_date);
$account = $this->account;
$str = '';
$total = 0;
$total = $data['desktop'] + $data['mobile'] + $data['webmail'];
foreach (['mobile', 'desktop', 'webmail'] as $platform) {
$percent = $total ? ($data[$platform] / $total * 100) : 0;
$str .= sprintf('<tr><td>%s</td><td>%s%%</td></tr>', trans('texts.' . $platform), $account->formatNumber($percent));
}
$this->response->platforms = $str;
}
private function loadEmailClientStats() {
$data = $this->postmark->getOutboundEmailClientStatistics($this->account->account_key, request()->start_date, request()->end_date);
$account = $this->account;
$str = '';
$total = 0;
$clients = [];
foreach ($data as $key => $val) {
if ($key == 'days') {
continue;
}
$total += $val;
$clients[$key] = $val;
}
arsort($clients);
foreach ($clients as $key => $val) {
$percent = $total ? ($val / $total * 100) : 0;
if ($percent < 0.5) {
continue;
}
$str .= sprintf('<tr><td>%s</td><td>%s%%</td></tr>', ucwords($key), $account->formatNumber($percent));
}
$this->response->emailClients = $str;
}
}

View File

@ -55,6 +55,7 @@ class PurgeAccountData extends Job
'proposal_snippets',
'proposal_categories',
'proposal_invitations',
'tax_rates',
];
foreach ($tables as $table) {

View File

@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Jobs\Job;
use App\Models\Invoice;
use App\Models\LookupAccount;
use DB;
use Exception;
use App\Libraries\HistoryUtils;
class PurgeClientData extends Job
{
public function __construct($client)
{
$this->client = $client;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
$invoices = $this->client->invoices()->withTrashed()->get();
$expenses = $this->client->expenses()->withTrashed()->get();
foreach ($invoices as $invoice) {
foreach ($invoice->documents as $document) {
$document->delete();
}
}
foreach ($expenses as $expense) {
foreach ($expense->documents as $document) {
$document->delete();
}
}
$this->client->forceDelete();
HistoryUtils::deleteHistory($this->client);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Jobs;
use App\Jobs\Job;
use Postmark\PostmarkClient;
class ReactivatePostmarkEmail extends Job
{
public function __construct($bounceId)
{
$this->bounceId = $bounceId;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if (! config('services.postmark')) {
return false;
}
$postmark = new PostmarkClient(config('services.postmark'));
$response = $postmark->activateBounce($this->bounceId);
}
}

View File

@ -31,6 +31,8 @@ class RunReport extends Job
$reportType = $this->reportType;
$config = $this->config;
$config['subgroup'] = false; // don't yet support charts in export
$isExport = $this->isExport;
$reportClass = '\\App\\Ninja\\Reports\\' . Str::studly($reportType) . 'Report';

View File

@ -66,4 +66,32 @@ class CurlUtils
return false;
}
}
public static function renderPDF($url, $filename)
{
if (! $path = env('PHANTOMJS_BIN_PATH')) {
return false;
}
$client = Client::getInstance();
$client->isLazy();
$client->getEngine()->addOption("--load-images=true");
$client->getEngine()->setPath($path);
$request = $client->getMessageFactory()->createPdfRequest($url, 'GET');
$request->setOutputFile($filename);
//$request->setOrientation('landscape');
$request->setMargin('0');
$response = $client->getMessageFactory()->createResponse();
$client->send($request, $response);
if ($response->getStatus() === 200) {
$pdf = file_get_contents($filename);
unlink($filename);
return $pdf;
} else {
return false;
}
}
}

View File

@ -46,6 +46,10 @@ class HistoryUtils
->get();
foreach ($activities->reverse() as $activity) {
if ($activity->client && $activity->client->is_deleted) {
continue;
}
if ($activity->activity_type_id == ACTIVITY_TYPE_CREATE_CLIENT) {
$entity = $activity->client;
} elseif ($activity->activity_type_id == ACTIVITY_TYPE_CREATE_TASK || $activity->activity_type_id == ACTIVITY_TYPE_UPDATE_TASK) {
@ -78,6 +82,28 @@ class HistoryUtils
}
}
public static function deleteHistory(EntityModel $entity)
{
$history = Session::get(RECENTLY_VIEWED) ?: [];
$accountHistory = isset($history[$entity->account_id]) ? $history[$entity->account_id] : [];
$remove = [];
for ($i=0; $i<count($accountHistory); $i++) {
$item = $accountHistory[$i];
if ($entity->equalTo($item)) {
$remove[] = $i;
} elseif ($entity->getEntityType() == ENTITY_CLIENT && $entity->public_id == $item->client_id) {
$remove[] = $i;
}
}
for ($i=count($remove) - 1; $i>=0; $i--) {
array_splice($history[$entity->account_id], $remove[$i], 1);
}
Session::put(RECENTLY_VIEWED, $history);
}
public static function trackViewed(EntityModel $entity)
{
$entityType = $entity->getEntityType();
@ -136,6 +162,7 @@ class HistoryUtils
private static function convertToObject($entity)
{
$object = new stdClass();
$object->id = $entity->id;
$object->accountId = $entity->account_id;
$object->url = $entity->present()->url;
$object->entityType = $entity->subEntityType();

View File

@ -435,6 +435,7 @@ class Utils
'url' => Input::get('url', Request::url()),
'previous' => url()->previous(),
'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
'locale' => App::getLocale(),
'ip' => Request::getClientIp(),
'count' => Session::get('error_count', 0),
'is_console' => App::runningInConsole() ? 'yes' : 'no',
@ -500,6 +501,21 @@ class Utils
return $data->first();
}
public static function formatNumber($value, $currencyId = false, $precision = 0)
{
$value = floatval($value);
if (! $currencyId) {
$currencyId = Session::get(SESSION_CURRENCY, DEFAULT_CURRENCY);
}
$currency = self::getFromCache($currencyId, 'currencies');
$thousand = $currency->thousand_separator;
$decimal = $currency->decimal_separator;
return number_format($value, $precision, $decimal, $thousand);
}
public static function formatMoney($value, $currencyId = false, $countryId = false, $decorator = false)
{
$value = floatval($value);
@ -1327,6 +1343,28 @@ class Utils
}
public static function brewerColor($number) {
$colors = [
'#337AB7',
'#3cb44b',
'#e6194b',
'#f58231',
'#911eb4',
'#46f0f0',
'#f032e6',
'#d2f53c',
'#fabebe',
'#008080',
'#e6beff',
'#aa6e28',
'#fffac8',
'#800000',
'#aaffc3',
'#808000',
'#000080',
'#808080',
];
/*
$colors = [
'#1c9f77',
'#d95d02',
@ -1337,11 +1375,18 @@ class Utils
'#a87821',
'#676767',
];
*/
$number = ($number-1) % count($colors);
return $colors[$number];
}
public static function brewerColorRGB($number) {
$color = static::brewerColor($number);
list($r, $g, $b) = sscanf($color, "#%02x%02x%02x");
return "{$r},{$g},{$b}";
}
/**
* Replace language-specific characters by ASCII-equivalents.
* @param string $s

View File

@ -71,7 +71,7 @@ class HandleUserLoggedIn
// if they're using Stripe make sure they're using Stripe.js
$accountGateway = $account->getGatewayConfig(GATEWAY_STRIPE);
if ($accountGateway && ! $accountGateway->getPublishableStripeKey()) {
if ($accountGateway && ! $accountGateway->getPublishableKey()) {
Session::flash('warning', trans('texts.missing_publishable_key'));
} elseif ($account->isLogoTooLarge()) {
Session::flash('warning', trans('texts.logo_too_large', ['size' => $account->getLogoSize() . 'KB']));

View File

@ -0,0 +1,74 @@
<?php
namespace App\Listeners;
use App\Events\InvoiceWasDeleted;
use App\Events\ClientWasDeleted;
use App\Events\QuoteWasDeleted;
use App\Events\TaskWasDeleted;
use App\Events\ExpenseWasDeleted;
use App\Events\ProjectWasDeleted;
use App\Events\ProposalWasDeleted;
use App\Libraries\HistoryUtils;
/**
* Class InvoiceListener.
*/
class HistoryListener
{
/**
* @param ClientWasDeleted $event
*/
public function deletedClient(ClientWasDeleted $event)
{
HistoryUtils::deleteHistory($event->client);
}
/**
* @param InvoiceWasDeleted $event
*/
public function deletedInvoice(InvoiceWasDeleted $event)
{
HistoryUtils::deleteHistory($event->invoice);
}
/**
* @param QuoteWasDeleted $event
*/
public function deletedQuote(QuoteWasDeleted $event)
{
HistoryUtils::deleteHistory($event->quote);
}
/**
* @param TaskWasDeleted $event
*/
public function deletedTask(TaskWasDeleted $event)
{
HistoryUtils::deleteHistory($event->task);
}
/**
* @param ExpenseWasDeleted $event
*/
public function deletedExpense(ExpenseWasDeleted $event)
{
HistoryUtils::deleteHistory($event->expense);
}
/**
* @param ProjectWasDeleted $event
*/
public function deletedProject(ProjectWasDeleted $event)
{
HistoryUtils::deleteHistory($event->project);
}
/**
* @param ProposalWasDeleted $event
*/
public function deletedProposal(ProposalWasDeleted $event)
{
HistoryUtils::deleteHistory($event->proposal);
}
}

View File

@ -90,6 +90,11 @@ class InvoiceListener
->first();
$activity->json_backup = $invoice->hidePrivateFields()->toJSON();
$activity->save();
if ($invoice->balance == 0 && $payment->account->auto_archive_invoice) {
$invoiceRepo = app('App\Ninja\Repositories\InvoiceRepository');
$invoiceRepo->archive($invoice);
}
}
/**

View File

@ -10,6 +10,8 @@ use App\Events\QuoteInvitationWasApproved;
use App\Events\PaymentWasCreated;
use App\Services\PushService;
use App\Jobs\SendNotificationEmail;
use App\Jobs\SendPaymentEmail;
use App\Notifications\PaymentCreated;
/**
* Class NotificationListener
@ -47,14 +49,17 @@ class NotificationListener
* @param $type
* @param null $payment
*/
private function sendEmails($invoice, $type, $payment = null, $notes = false)
private function sendNotifications($invoice, $type, $payment = null, $notes = false)
{
foreach ($invoice->account->users as $user)
{
if ($user->{"notify_{$type}"})
{
if ($user->{"notify_{$type}"}) {
dispatch(new SendNotificationEmail($user, $invoice, $type, $payment, $notes));
}
if ($payment && $user->slack_webhook_url) {
$user->notify(new PaymentCreated($payment, $invoice));
}
}
}
@ -63,7 +68,7 @@ class NotificationListener
*/
public function emailedInvoice(InvoiceWasEmailed $event)
{
$this->sendEmails($event->invoice, 'sent', null, $event->notes);
$this->sendNotifications($event->invoice, 'sent', null, $event->notes);
$this->pushService->sendNotification($event->invoice, 'sent');
}
@ -72,7 +77,7 @@ class NotificationListener
*/
public function emailedQuote(QuoteWasEmailed $event)
{
$this->sendEmails($event->quote, 'sent', null, $event->notes);
$this->sendNotifications($event->quote, 'sent', null, $event->notes);
$this->pushService->sendNotification($event->quote, 'sent');
}
@ -85,7 +90,7 @@ class NotificationListener
return;
}
$this->sendEmails($event->invoice, 'viewed');
$this->sendNotifications($event->invoice, 'viewed');
$this->pushService->sendNotification($event->invoice, 'viewed');
}
@ -98,7 +103,7 @@ class NotificationListener
return;
}
$this->sendEmails($event->quote, 'viewed');
$this->sendNotifications($event->quote, 'viewed');
$this->pushService->sendNotification($event->quote, 'viewed');
}
@ -107,7 +112,7 @@ class NotificationListener
*/
public function approvedQuote(QuoteInvitationWasApproved $event)
{
$this->sendEmails($event->quote, 'approved');
$this->sendNotifications($event->quote, 'approved');
$this->pushService->sendNotification($event->quote, 'approved');
}
@ -121,8 +126,8 @@ class NotificationListener
return;
}
$this->contactMailer->sendPaymentConfirmation($event->payment);
$this->sendEmails($event->payment->invoice, 'paid', $event->payment);
dispatch(new SendPaymentEmail($event->payment));
$this->sendNotifications($event->payment->invoice, 'paid', $event->payment);
$this->pushService->sendNotification($event->payment->invoice, 'paid');
}

View File

@ -134,6 +134,9 @@ class Account extends Eloquent
'header_font_id',
'body_font_id',
'auto_convert_quote',
'auto_archive_quote',
'auto_archive_invoice',
'auto_email_invoice',
'all_pages_footer',
'all_pages_header',
'show_currency_code',
@ -170,6 +173,7 @@ class Account extends Eloquent
'reset_counter_frequency_id',
'payment_type_id',
'gateway_fee_enabled',
'send_item_details',
'reset_counter_date',
'custom_contact_label1',
'custom_contact_label2',
@ -232,6 +236,7 @@ class Account extends Eloquent
public static $customLabels = [
'balance_due',
'credit_card',
'delivery_note',
'description',
'discount',
'due_date',
@ -256,6 +261,7 @@ class Account extends Eloquent
'tax',
'terms',
'unit_cost',
'valid_until',
'vat_number',
];
@ -624,12 +630,12 @@ class Account extends Eloquent
*
* @return DateTime|null|string
*/
public function getDateTime($date = 'now')
public function getDateTime($date = 'now', $formatted = false)
{
$date = $this->getDate($date);
$date->setTimeZone(new \DateTimeZone($this->getTimezone()));
return $date;
return $formatted ? $date->format($this->getCustomDateTimeFormat()) : $date;
}
/**
@ -682,6 +688,17 @@ class Account extends Eloquent
return Utils::formatMoney($amount, $currencyId, $countryId, $decorator);
}
public function formatNumber($amount, $precision = 0)
{
if ($this->currency_id) {
$currencyId = $this->currency_id;
} else {
$currencyId = DEFAULT_CURRENCY;
}
return Utils::formatNumber($amount, $currencyId, $precision);
}
/**
* @return mixed
*/
@ -1780,6 +1797,11 @@ class Account extends Eloquent
return url('/');
}
}
public function requiresAddressState() {
return true;
//return ! $this->country_id || $this->country_id == DEFAULT_COUNTRY;
}
}
Account::creating(function ($account)

View File

@ -95,8 +95,17 @@ class AccountGateway extends EntityModel
*/
public function isGateway($gatewayId)
{
if (is_array($gatewayId)) {
foreach ($gatewayId as $id) {
if ($this->gateway_id == $id) {
return true;
}
}
return false;
} else {
return $this->gateway_id == $gatewayId;
}
}
/**
* @param $config
@ -127,9 +136,9 @@ class AccountGateway extends EntityModel
/**
* @return bool|mixed
*/
public function getPublishableStripeKey()
public function getPublishableKey()
{
if (! $this->isGateway(GATEWAY_STRIPE)) {
if (! $this->isGateway([GATEWAY_STRIPE, GATEWAY_PAYMILL])) {
return false;
}
@ -254,7 +263,7 @@ class AccountGateway extends EntityModel
return null;
}
$stripe_key = $this->getPublishableStripeKey();
$stripe_key = $this->getPublishableKey();
return substr(trim($stripe_key), 0, 8) == 'pk_test_' ? 'tartan' : 'production';
}
@ -272,7 +281,7 @@ class AccountGateway extends EntityModel
public function isTestMode()
{
if ($this->isGateway(GATEWAY_STRIPE)) {
return strpos($this->getPublishableStripeKey(), 'test') !== false;
return strpos($this->getPublishableKey(), 'test') !== false;
} else {
return $this->getConfigField('testMode');
}

View File

@ -137,4 +137,71 @@ class Activity extends Eloquent
return trans("texts.activity_{$activityTypeId}", $data);
}
public function relatedEntityType()
{
switch ($this->activity_type_id) {
case ACTIVITY_TYPE_CREATE_CLIENT:
case ACTIVITY_TYPE_ARCHIVE_CLIENT:
case ACTIVITY_TYPE_DELETE_CLIENT:
case ACTIVITY_TYPE_RESTORE_CLIENT:
case ACTIVITY_TYPE_CREATE_CREDIT:
case ACTIVITY_TYPE_ARCHIVE_CREDIT:
case ACTIVITY_TYPE_DELETE_CREDIT:
case ACTIVITY_TYPE_RESTORE_CREDIT:
return ENTITY_CLIENT;
break;
case ACTIVITY_TYPE_CREATE_INVOICE:
case ACTIVITY_TYPE_UPDATE_INVOICE:
case ACTIVITY_TYPE_EMAIL_INVOICE:
case ACTIVITY_TYPE_VIEW_INVOICE:
case ACTIVITY_TYPE_ARCHIVE_INVOICE:
case ACTIVITY_TYPE_DELETE_INVOICE:
case ACTIVITY_TYPE_RESTORE_INVOICE:
return ENTITY_INVOICE;
break;
case ACTIVITY_TYPE_CREATE_PAYMENT:
case ACTIVITY_TYPE_ARCHIVE_PAYMENT:
case ACTIVITY_TYPE_DELETE_PAYMENT:
case ACTIVITY_TYPE_RESTORE_PAYMENT:
case ACTIVITY_TYPE_VOIDED_PAYMENT:
case ACTIVITY_TYPE_REFUNDED_PAYMENT:
case ACTIVITY_TYPE_FAILED_PAYMENT:
return ENTITY_PAYMENT;
break;
case ACTIVITY_TYPE_CREATE_QUOTE:
case ACTIVITY_TYPE_UPDATE_QUOTE:
case ACTIVITY_TYPE_EMAIL_QUOTE:
case ACTIVITY_TYPE_VIEW_QUOTE:
case ACTIVITY_TYPE_ARCHIVE_QUOTE:
case ACTIVITY_TYPE_DELETE_QUOTE:
case ACTIVITY_TYPE_RESTORE_QUOTE:
case ACTIVITY_TYPE_APPROVE_QUOTE:
return ENTITY_QUOTE;
break;
case ACTIVITY_TYPE_CREATE_VENDOR:
case ACTIVITY_TYPE_ARCHIVE_VENDOR:
case ACTIVITY_TYPE_DELETE_VENDOR:
case ACTIVITY_TYPE_RESTORE_VENDOR:
case ACTIVITY_TYPE_CREATE_EXPENSE:
case ACTIVITY_TYPE_ARCHIVE_EXPENSE:
case ACTIVITY_TYPE_DELETE_EXPENSE:
case ACTIVITY_TYPE_RESTORE_EXPENSE:
case ACTIVITY_TYPE_UPDATE_EXPENSE:
return ENTITY_EXPENSE;
break;
case ACTIVITY_TYPE_CREATE_TASK:
case ACTIVITY_TYPE_UPDATE_TASK:
case ACTIVITY_TYPE_ARCHIVE_TASK:
case ACTIVITY_TYPE_DELETE_TASK:
case ACTIVITY_TYPE_RESTORE_TASK:
return ENTITY_TASK;
break;
}
}
}

View File

@ -262,7 +262,7 @@ class Client extends EntityModel
// check if this client wasRecentlyCreated to ensure a new contact is
// always created even if the request includes a contact id
if (! $this->wasRecentlyCreated && $publicId && $publicId != '-1') {
$contact = Contact::scope($publicId)->firstOrFail();
$contact = Contact::scope($publicId)->whereClientId($this->id)->firstOrFail();
} else {
$contact = Contact::createNew();
$contact->send_invoice = true;

View File

@ -155,18 +155,18 @@ class EntityModel extends Eloquent
*/
public function scopeScope($query, $publicId = false, $accountId = false)
{
if (! $accountId) {
$accountId = Auth::user()->account_id;
}
$query->where($this->getTable() .'.account_id', '=', $accountId);
// If 'false' is passed as the publicId return nothing rather than everything
if (func_num_args() > 1 && ! $publicId && ! $accountId) {
$query->where('id', '=', 0);
return $query;
}
if (! $accountId) {
$accountId = Auth::user()->account_id;
}
$query->where($this->getTable() .'.account_id', '=', $accountId);
if ($publicId) {
if (is_array($publicId)) {
$query->whereIn('public_id', $publicId);
@ -182,6 +182,15 @@ class EntityModel extends Eloquent
return $query;
}
public function scopeWithActiveOrSelected($query, $id = false)
{
return $query->withTrashed()
->where(function ($query) use ($id) {
$query->whereNull('deleted_at')
->orWhere('id', '=', $id);
});
}
/**
* @param $query
*
@ -427,4 +436,13 @@ class EntityModel extends Eloquent
throw $exception;
}
}
public function equalTo($obj)
{
if (empty($obj->id)) {
return false;
}
return $this->id == $obj->id && $this->getEntityType() == $obj->entityType;
}
}

View File

@ -369,7 +369,7 @@ class Invoice extends EntityModel implements BalanceAffecting
*/
public function quote()
{
return $this->belongsTo('App\Models\Invoice');
return $this->belongsTo('App\Models\Invoice')->withTrashed();
}
/**
@ -1093,67 +1093,6 @@ class Invoice extends EntityModel implements BalanceAffecting
return $this;
}
/**
* @throws \Recurr\Exception\MissingData
*
* @return bool|\Recurr\RecurrenceCollection
*/
public function getSchedule()
{
if (! $this->start_date || ! $this->is_recurring || ! $this->frequency_id) {
return false;
}
$startDate = $this->getOriginal('last_sent_date') ?: $this->getOriginal('start_date');
$startDate .= ' ' . $this->account->recurring_hour . ':00:00';
$startDate = $this->account->getDateTime($startDate);
$endDate = $this->end_date ? $this->account->getDateTime($this->getOriginal('end_date')) : null;
$timezone = $this->account->getTimezone();
$rule = $this->getRecurrenceRule();
$rule = new \Recurr\Rule("{$rule}", $startDate, $endDate, $timezone);
// Fix for months with less than 31 days
$transformerConfig = new \Recurr\Transformer\ArrayTransformerConfig();
$transformerConfig->enableLastDayOfMonthFix();
$transformer = new \Recurr\Transformer\ArrayTransformer();
$transformer->setConfig($transformerConfig);
$dates = $transformer->transform($rule);
if (count($dates) < 2) {
return false;
}
return $dates;
}
/**
* @return null
*/
public function getNextSendDate()
{
if (! $this->is_public) {
return null;
}
if ($this->start_date && ! $this->last_sent_date) {
$startDate = $this->getOriginal('start_date') . ' ' . $this->account->recurring_hour . ':00:00';
return $this->account->getDateTime($startDate);
}
if (! $schedule = $this->getSchedule()) {
return null;
}
if (count($schedule) < 2) {
return null;
}
return $schedule[1]->getStart();
}
/**
* @param null $invoice_date
*
@ -1258,7 +1197,7 @@ class Invoice extends EntityModel implements BalanceAffecting
*
* @return null
*/
public function getPrettySchedule($min = 1, $max = 10)
public function getPrettySchedule($min = 0, $max = 10)
{
if (! $schedule = $this->getSchedule($max)) {
return null;
@ -1310,6 +1249,9 @@ class Invoice extends EntityModel implements BalanceAffecting
if (strpos($pdfString, 'data') === 0) {
break;
} else {
if (Utils::isNinjaDev() || Utils::isTravis()) {
Utils::logError('Failed to generate: ' . $i);
}
$pdfString = false;
sleep(2);
}

View File

@ -96,6 +96,25 @@ class Proposal extends EntityModel
{
return $this->invoice->invoice_number;
}
public function getLink($forceOnsite = false, $forcePlain = false)
{
$invitation = $this->invitations->first();
return $invitation->getLink('proposal', $forceOnsite, $forcePlain);
}
public function getHeadlessLink()
{
return sprintf('%s?phantomjs=true&phantomjs_secret=%s', $this->getLink(true, true), env('PHANTOMJS_SECRET'));
}
public function getFilename($extension = 'pdf')
{
$entityType = $this->getEntityType();
return trans('texts.proposal') . '_' . $this->invoice->invoice_number . '.' . $extension;
}
}
Proposal::creating(function ($project) {

View File

@ -145,6 +145,18 @@ trait HasLogo
return round($this->logo_size / 1000);
}
/**
* @return string|null
*/
public function getLogoName()
{
if (! $this->hasLogo()) {
return null;
}
return $this->logo;
}
/**
* @return bool
*/

View File

@ -14,6 +14,7 @@ trait HasRecurrence
/**
* @return bool
*/
/*
public function shouldSendToday()
{
if (! $this->user->confirmed) {
@ -78,6 +79,101 @@ trait HasRecurrence
return false;
}
*/
public function shouldSendToday()
{
if (! $this->user->confirmed) {
return false;
}
$account = $this->account;
$timezone = $account->getTimezone();
if (! $this->start_date || Carbon::parse($this->start_date, $timezone)->isFuture()) {
return false;
}
if ($this->end_date && Carbon::parse($this->end_date, $timezone)->isPast()) {
return false;
}
if (! $this->last_sent_date) {
return true;
} else {
// check we don't send a few hours early due to timezone difference
if (Utils::isNinja() && Carbon::now()->format('Y-m-d') != Carbon::now($timezone)->format('Y-m-d')) {
return false;
}
$nextSendDate = $this->getNextSendDate();
if (! $nextSendDate) {
return false;
}
return $this->account->getDateTime() >= $nextSendDate;
}
}
/**
* @throws \Recurr\Exception\MissingData
*
* @return bool|\Recurr\RecurrenceCollection
*/
public function getSchedule()
{
if (! $this->start_date || ! $this->frequency_id) {
return false;
}
$startDate = $this->getOriginal('last_sent_date') ?: $this->getOriginal('start_date');
$startDate .= ' ' . $this->account->recurring_hour . ':00:00';
$timezone = $this->account->getTimezone();
$rule = $this->getRecurrenceRule();
$rule = new \Recurr\Rule("{$rule}", $startDate, null, $timezone);
// Fix for months with less than 31 days
$transformerConfig = new \Recurr\Transformer\ArrayTransformerConfig();
$transformerConfig->enableLastDayOfMonthFix();
$transformer = new \Recurr\Transformer\ArrayTransformer();
$transformer->setConfig($transformerConfig);
$dates = $transformer->transform($rule);
if (count($dates) < 1) {
return false;
}
return $dates;
}
/**
* @return null
*/
public function getNextSendDate()
{
if (! $this->is_public) {
return null;
}
if ($this->start_date && ! $this->last_sent_date) {
$startDate = $this->getOriginal('start_date') . ' ' . $this->account->recurring_hour . ':00:00';
return $this->account->getDateTime($startDate);
}
if (! $schedule = $this->getSchedule()) {
return null;
}
if (count($schedule) < 2) {
return null;
}
return $schedule[1]->getStart();
}
/**
* @return string
@ -120,21 +216,9 @@ trait HasRecurrence
}
if ($this->end_date) {
$rule .= 'UNTIL=' . $this->getOriginal('end_date');
$rule .= 'UNTIL=' . $this->getOriginal('end_date') . ' 24:00:00';
}
return $rule;
}
/*
public function shouldSendToday()
{
if (!$nextSendDate = $this->getNextSendDate()) {
return false;
}
return $this->account->getDateTime() >= $nextSendDate;
}
*/
}

View File

@ -336,6 +336,7 @@ trait PresentsInvoice
'custom_value1',
'custom_value2',
'delivery_note',
'date',
];
foreach ($fields as $field) {

View File

@ -70,6 +70,7 @@ class User extends Authenticatable
'google_2fa_secret',
'google_2fa_phone',
'remember_2fa_token',
'slack_webhook_url',
];
/**
@ -446,6 +447,29 @@ class User extends Authenticatable
//$this->notify(new ResetPasswordNotification($token));
app('App\Ninja\Mailers\UserMailer')->sendPasswordReset($this, $token);
}
public function routeNotificationForSlack()
{
return $this->slack_webhook_url;
}
public function hasAcceptedLatestTerms()
{
if (! NINJA_TERMS_VERSION) {
return true;
}
return $this->accepted_terms_version == NINJA_TERMS_VERSION;
}
public function acceptLatestTerms($ip)
{
$this->accepted_terms_version = NINJA_TERMS_VERSION;
$this->accepted_terms_timestamp = date('Y-m-d H:i:s');
$this->accepted_terms_ip = $ip;
return $this;
}
}
User::created(function ($user)

View File

@ -216,8 +216,8 @@ class Vendor extends EntityModel
{
$publicId = isset($data['public_id']) ? $data['public_id'] : (isset($data['id']) ? $data['id'] : false);
if ($publicId && $publicId != '-1') {
$contact = VendorContact::scope($publicId)->firstOrFail();
if (! $this->wasRecentlyCreated && $publicId && $publicId != '-1') {
$contact = VendorContact::scope($publicId)->whereVendorId($this->id)->firstOrFail();
} else {
$contact = VendorContact::createNew();
}

View File

@ -143,7 +143,7 @@ class PaymentDatatable extends EntityDatatable
[
trans('texts.refund_payment'),
function ($model) {
$max_refund = number_format($model->amount - $model->refunded, 2);
$max_refund = $model->amount - $model->refunded;
$formatted = Utils::formatMoney($max_refund, $model->currency_id, $model->country_id);
$symbol = Utils::getFromCache($model->currency_id ? $model->currency_id : 1, 'currencies')->symbol;
$local = in_array($model->gateway_id, [GATEWAY_BRAINTREE, GATEWAY_STRIPE, GATEWAY_WEPAY]) || ! $model->gateway_id ? 0 : 1;

View File

@ -68,6 +68,15 @@ class ProductDatatable extends EntityDatatable
return URL::to("products/{$model->public_id}/edit");
},
],
[
trans('texts.clone_product'),
function ($model) {
return URL::to("products/{$model->public_id}/clone");
},
function ($model) {
return Auth::user()->can('create', ENTITY_PRODUCT);
},
],
[
trans('texts.invoice_product'),
function ($model) {

View File

@ -191,7 +191,7 @@ class ContactMailer extends Mailer
$data = [
'body' => $this->templateService->processVariables($body, $variables),
'link' => $invitation->getLink(),
'entityType' => $invoice->getEntityType(),
'entityType' => $proposal ? ENTITY_PROPOSAL : $invoice->getEntityType(),
'invoiceId' => $invoice->id,
'invitation' => $invitation,
'account' => $account,

View File

@ -6,6 +6,9 @@ use App\Models\Invoice;
use Exception;
use Mail;
use Utils;
use Postmark\PostmarkClient;
use Postmark\Models\PostmarkException;
use Postmark\Models\PostmarkAttachment;
/**
* Class Mailer.
@ -34,6 +37,25 @@ class Mailer
'emails.'.$view.'_text',
];
$toEmail = strtolower($toEmail);
$replyEmail = $fromEmail;
$fromEmail = CONTACT_EMAIL;
//\Log::info("{$toEmail} | {$replyEmail} | $fromEmail");
// Optionally send for alternate domain
if (! empty($data['fromEmail'])) {
$fromEmail = $data['fromEmail'];
}
if (config('services.postmark')) {
return $this->sendPostmarkMail($toEmail, $fromEmail, $fromName, $replyEmail, $subject, $views, $data);
} else {
return $this->sendLaravelMail($toEmail, $fromEmail, $fromName, $replyEmail, $subject, $views, $data);
}
}
private function sendLaravelMail($toEmail, $fromEmail, $fromName, $replyEmail, $subject, $views, $data = [])
{
if (Utils::isSelfHost()) {
if (isset($data['account'])) {
$account = $data['account'];
@ -60,17 +82,7 @@ class Mailer
}
try {
$response = Mail::send($views, $data, function ($message) use ($toEmail, $fromEmail, $fromName, $subject, $data) {
$toEmail = strtolower($toEmail);
$replyEmail = $fromEmail;
$fromEmail = CONTACT_EMAIL;
//\Log::info("{$toEmail} | {$replyEmail} | $fromEmail");
// Optionally send for alternate domain
if (! empty($data['fromEmail'])) {
$fromEmail = $data['fromEmail'];
}
$response = Mail::send($views, $data, function ($message) use ($toEmail, $fromEmail, $fromName, $replyEmail, $subject, $data) {
$message->to($toEmail)
->from($fromEmail, $fromName)
->replyTo($replyEmail, $fromName)
@ -95,9 +107,77 @@ class Mailer
}
});
return $this->handleSuccess($response, $data);
return $this->handleSuccess($data);
} catch (Exception $exception) {
return $this->handleFailure($exception);
return $this->handleFailure($data, $exception->getMessage());
}
}
private function sendPostmarkMail($toEmail, $fromEmail, $fromName, $replyEmail, $subject, $views, $data = [])
{
$htmlBody = view($views[0], $data)->render();
$textBody = view($views[1], $data)->render();
$attachments = [];
if (isset($data['account'])) {
$account = $data['account'];
$logoName = $account->getLogoName();
if (strpos($htmlBody, 'cid:' . $logoName) !== false && $account->hasLogo()) {
$attachments[] = PostmarkAttachment::fromFile($account->getLogoPath(), $logoName, null, 'cid:' . $logoName);
}
}
if (strpos($htmlBody, 'cid:invoiceninja-logo.png') !== false) {
$attachments[] = PostmarkAttachment::fromFile(public_path('images/invoiceninja-logo.png'), 'invoiceninja-logo.png', null, 'cid:invoiceninja-logo.png');
$attachments[] = PostmarkAttachment::fromFile(public_path('images/emails/icon-facebook.png'), 'icon-facebook.png', null, 'cid:icon-facebook.png');
$attachments[] = PostmarkAttachment::fromFile(public_path('images/emails/icon-twitter.png'), 'icon-twitter.png', null, 'cid:icon-twitter.png');
$attachments[] = PostmarkAttachment::fromFile(public_path('images/emails/icon-github.png'), 'icon-github.png', null, 'cid:icon-github.png');
}
// Handle invoice attachments
if (! empty($data['pdfString']) && ! empty($data['pdfFileName'])) {
$attachments[] = PostmarkAttachment::fromRawData($data['pdfString'], $data['pdfFileName']);
}
if (! empty($data['ublString']) && ! empty($data['ublFileName'])) {
$attachments[] = PostmarkAttachment::fromRawData($data['ublString'], $data['ublFileName']);
}
if (! empty($data['documents'])) {
foreach ($data['documents'] as $document) {
$attachments[] = PostmarkAttachment::fromRawData($document['data'], $document['name']);
}
}
try {
$client = new PostmarkClient(config('services.postmark'));
$message = [
'To' => $toEmail,
'From' => $fromEmail,
'ReplyTo' => $replyEmail,
'Subject' => $subject,
'TextBody' => $textBody,
'HtmlBody' => $htmlBody,
'Attachments' => $attachments,
];
if (! empty($data['bccEmail'])) {
$message['Bcc'] = $data['bccEmail'];
}
if (! empty($data['account'])) {
$message['Tag'] = $data['account']->account_key;
}
$response = $client->sendEmailBatch([$message]);
if ($messageId = $response[0]->messageid) {
return $this->handleSuccess($data, $messageId);
} else {
return $this->handleFailure($data, $response[0]->message);
}
} catch (PostmarkException $exception) {
return $this->handleFailure($data, $exception->getMessage());
} catch (Exception $exception) {
Utils::logError(Utils::getErrorString($exception));
throw $exception;
}
}
@ -107,19 +187,11 @@ class Mailer
*
* @return bool
*/
private function handleSuccess($response, $data)
private function handleSuccess($data, $messageId = false)
{
if (isset($data['invitation'])) {
$invitation = $data['invitation'];
$invoice = $invitation->invoice;
$messageId = false;
// Track the Postmark message id
if (isset($_ENV['POSTMARK_API_TOKEN']) && $response) {
$json = json_decode((string) $response->getBody());
$messageId = $json->MessageID;
}
$notes = isset($data['notes']) ? $data['notes'] : false;
if (! empty($data['proposal'])) {
@ -137,35 +209,14 @@ class Mailer
*
* @return string
*/
private function handleFailure($exception)
private function handleFailure($data, $emailError)
{
if (isset($_ENV['POSTMARK_API_TOKEN']) && method_exists($exception, 'getResponse')) {
$response = $exception->getResponse();
if (! $response) {
$error = trans('texts.postmark_error', ['link' => link_to('https://status.postmarkapp.com/')]);
Utils::logError($error);
if (config('queue.default') === 'sync') {
return $error;
} else {
throw $exception;
}
}
$response = $response->getBody()->getContents();
$response = json_decode($response);
$emailError = nl2br($response->Message);
} else {
$emailError = $exception->getMessage();
}
if (isset($data['invitation'])) {
$invitation = $data['invitation'];
$invitation->email_error = $emailError;
$invitation->save();
} elseif (! Utils::isNinjaProd()) {
Utils::logError(Utils::getErrorString($exception));
Utils::logError($emailError);
}
return $emailError;

View File

@ -14,4 +14,11 @@ class AuthorizeNetAIMPaymentDriver extends BasePaymentDriver
return $data;
}
public function createPayment($ref = false, $paymentMethod = null)
{
$ref = $this->purchaseResponse['transactionResponse']['transId'] ?: $this->purchaseResponse['refId'];
parent::createPayment($ref, $paymentMethod);
}
}

View File

@ -164,8 +164,8 @@ class BasePaymentDriver
}
$url = 'payment/' . $this->invitation->invitation_key;
if (request()->update) {
$url .= '?update=true';
if (request()->capture) {
$url .= '?capture=true';
}
$data = [
@ -242,10 +242,13 @@ class BasePaymentDriver
$rules = array_merge($rules, [
'address1' => 'required',
'city' => 'required',
'state' => 'required',
'postal_code' => 'required',
'country_id' => 'required',
]);
if ($this->account()->requiresAddressState()) {
$rules['state'] = 'required';
}
}
}
@ -266,7 +269,7 @@ class BasePaymentDriver
public function completeOnsitePurchase($input = false, $paymentMethod = false)
{
$this->input = count($input) ? $input : false;
$this->input = $input && count($input) ? $input : false;
$gateway = $this->gateway();
if ($input) {
@ -303,17 +306,19 @@ class BasePaymentDriver
}
}
if ($this->isTwoStep() || request()->update) {
if ($this->isTwoStep() || request()->capture) {
return;
}
// prepare and process payment
$data = $this->paymentDetails($paymentMethod);
// TODO move to payment driver class
if ($this->isGateway(GATEWAY_SAGE_PAY_DIRECT) || $this->isGateway(GATEWAY_SAGE_PAY_SERVER)) {
$items = null;
} elseif ($this->account()->send_item_details) {
$items = $this->paymentItems();
} else {
//$items = $this->paymentItems();
$items = null;
}
$response = $gateway->purchase($data)
@ -369,7 +374,7 @@ class BasePaymentDriver
$item = new Item([
'name' => $invoiceItem->product_key,
'description' => $invoiceItem->notes,
'description' => substr($invoiceItem->notes, 0, 100),
'price' => $invoiceItem->cost,
'quantity' => $invoiceItem->qty,
]);
@ -867,6 +872,7 @@ class BasePaymentDriver
return [
'amount' => $amount,
'transactionReference' => $payment->transaction_reference,
'currency' => $payment->client->getCurrencyCode(),
];
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Ninja\PaymentDrivers;
class PaymillPaymentDriver extends BasePaymentDriver
{
public function tokenize()
{
return true;
}
protected function paymentDetails($paymentMethod = false)
{
$data = parent::paymentDetails($paymentMethod);
if ($paymentMethod) {
return $data;
}
if (! empty($this->input['sourceToken'])) {
$data['token'] = $this->input['sourceToken'];
unset($data['card']);
}
return $data;
}
}

View File

@ -63,7 +63,7 @@ class StripePaymentDriver extends BasePaymentDriver
public function tokenize()
{
return $this->accountGateway->getPublishableStripeKey();
return $this->accountGateway->getPublishableKey();
}
public function rules()

View File

@ -44,6 +44,11 @@ class ExpensePresenter extends EntityPresenter
return Utils::formatMoney($this->entity->amountWithTax(), $this->entity->expense_currency_id);
}
public function currencyCode()
{
return Utils::getFromCache($this->entity->expense_currency_id, 'currencies')->code;
}
public function taxAmount()
{
return Utils::formatMoney($this->entity->taxAmount(), $this->entity->expense_currency_id);

View File

@ -27,10 +27,16 @@ class ProductPresenter extends EntityPresenter
public function moreActions()
{
$product = $this->entity;
$actions = [];
if (! $product->trashed()) {
if (auth()->user()->can('create', ENTITY_PRODUCT)) {
$actions[] = ['url' => 'javascript:submitAction("clone")', 'label' => trans('texts.clone_product')];
}
if (auth()->user()->can('create', ENTITY_INVOICE)) {
$actions[] = ['url' => 'javascript:submitAction("invoice")', 'label' => trans('texts.invoice_product')];
}
if (count($actions)) {
$actions[] = DropdownButton::DIVIDER;
}
$actions[] = ['url' => 'javascript:submitAction("archive")', 'label' => trans("texts.archive_product")];

View File

@ -16,7 +16,7 @@ class ProposalPresenter extends EntityPresenter
$invitation = $proposal->invitations->first();
$actions = [];
$actions[] = ['url' => $invitation->getLink('proposal'), 'label' => trans("texts.view_as_recipient")];
$actions[] = ['url' => $invitation->getLink('proposal'), 'label' => trans("texts.view_in_portal")];
$actions[] = DropdownButton::DIVIDER;

View File

@ -2,7 +2,13 @@
namespace App\Ninja\Reports;
use Utils;
use Auth;
use Carbon;
use DateInterval;
use DatePeriod;
use stdClass;
use App\Models\Client;
class AbstractReport
{
@ -13,6 +19,7 @@ class AbstractReport
public $totals = [];
public $data = [];
public $chartData = [];
public function __construct($startDate, $endDate, $isExport, $options = false)
{
@ -69,7 +76,7 @@ class AbstractReport
}
if (strpos($field, 'date') !== false) {
$class[] = 'group-date-' . (isset($this->options['group_dates_by']) ? $this->options['group_dates_by'] : 'monthyear');
$class[] = 'group-date-' . (isset($this->options['group']) ? $this->options['group'] : 'monthyear');
} elseif (in_array($field, ['client', 'vendor', 'product', 'user', 'method', 'category', 'project'])) {
$class[] = 'group-letter-100';
} elseif (in_array($field, ['amount', 'paid', 'balance'])) {
@ -141,4 +148,166 @@ class AbstractReport
return join('', $reportParts);
}
protected function getDimension($entity)
{
$subgroup = $this->options['subgroup'];
if ($subgroup == 'user') {
return $entity->user->getDisplayName();
} elseif ($subgroup == 'client') {
if ($entity instanceof Client) {
return $entity->getDisplayName();
} elseif ($entity->client) {
return $entity->client->getDisplayName();
} else {
return trans('texts.unset');
}
}
}
protected function addChartData($dimension, $date, $amount)
{
if (! isset($this->chartData[$dimension])) {
$this->chartData[$dimension] = [];
}
$date = $this->formatDate($date);
if (! isset($this->chartData[$dimension][$date])) {
$this->chartData[$dimension][$date] = 0;
}
$this->chartData[$dimension][$date] += $amount;
}
public function chartGroupBy()
{
$groupBy = empty($this->options['group']) ? 'day' : $this->options['group'];
if ($groupBy == 'monthyear') {
$groupBy = 'month';
}
return strtoupper($groupBy);
}
protected function formatDate($date)
{
if (! $date instanceof \DateTime) {
$date = new \DateTime($date);
}
$groupBy = $this->chartGroupBy();
$dateFormat = $groupBy == 'DAY' ? 'z' : ($groupBy == 'MONTH' ? 'm' : '');
return $date->format('Y' . $dateFormat);
}
public function getLineChartData()
{
$startDate = date_create($this->startDate);
$endDate = date_create($this->endDate);
$groupBy = $this->chartGroupBy();
$datasets = [];
$labels = [];
foreach ($this->chartData as $dimension => $data) {
$interval = new DateInterval('P1'.substr($groupBy, 0, 1));
$intervalStartDate = Carbon::instance($startDate);
$intervalEndDate = Carbon::instance($endDate);
// round dates to match grouping
$intervalStartDate->hour(0)->minute(0)->second(0);
$intervalEndDate->hour(24)->minute(0)->second(0);
if ($groupBy == 'MONTHYEAR' || $groupBy == 'YEAR') {
$intervalStartDate->day(1);
$intervalEndDate->addMonth(1)->day(1);
}
if ($groupBy == 'YEAR') {
$intervalStartDate->month(1);
$intervalEndDate->month(12);
}
$period = new DatePeriod($intervalStartDate, $interval, $intervalEndDate);
$records = [];
foreach ($period as $date) {
$labels[] = $date->format('m/d/Y');
$date = $this->formatDate($date);
$records[] = isset($data[$date]) ? $data[$date] : 0;
}
$record = new stdClass();
$datasets[] = $record;
$color = Utils::brewerColorRGB(count($datasets));
$record->data = $records;
$record->label = $dimension;
$record->lineTension = 0;
$record->borderWidth = 3;
$record->borderColor = "rgba({$color}, 1)";
$record->backgroundColor = "rgba(255,255,255,0)";
}
$data = new stdClass();
$data->labels = $labels;
$data->datasets = $datasets;
return $data;
}
public function isLineChartEnabled()
{
return $this->options['group'];
}
public function isPieChartEnabled()
{
return $this->options['subgroup'];
}
public function getPieChartData()
{
if (! $this->isPieChartEnabled()) {
return false;
}
$datasets = [];
$labels = [];
$totals = [];
foreach ($this->chartData as $dimension => $data) {
foreach ($data as $date => $value) {
if (! isset($totals[$dimension])) {
$totals[$dimension] = 0;
}
$totals[$dimension] += $value;
}
}
$response = new stdClass();
$response->labels = [];
$datasets = new stdClass();
$datasets->data = [];
$datasets->backgroundColor = [];
foreach ($totals as $dimension => $value) {
$response->labels[] = $dimension;
$datasets->data[] = $value;
$datasets->lineTension = 0;
$datasets->borderWidth = 3;
$color = count($totals) ? Utils::brewerColorRGB(count($response->labels)) : '51,122,183';
$datasets->borderColor[] = "rgba({$color}, 1)";
$datasets->backgroundColor[] = "rgba({$color}, 0.1)";
}
$response->datasets = [$datasets];
return $response;
}
}

View File

@ -23,6 +23,7 @@ class ActivityReport extends AbstractReport
$startDate = $this->startDate;;
$endDate = $this->endDate;
$subgroup = $this->options['subgroup'];
$activities = Activity::scope()
->with('client.contacts', 'user', 'invoice', 'payment', 'credit', 'task', 'expense', 'account')
@ -37,8 +38,16 @@ class ActivityReport extends AbstractReport
$activity->present()->user,
$this->isExport ? strip_tags($activity->getMessage()) : $activity->getMessage(),
];
if ($subgroup == 'category') {
$dimension = trans('texts.' . $activity->relatedEntityType());
} else {
$dimension = $this->getDimension($activity);
}
$this->addChartData($dimension, $activity->created_at, 1);
}
//dd($this->getChartData());
}
}

View File

@ -24,6 +24,7 @@ class AgingReport extends AbstractReport
public function run()
{
$account = Auth::user()->account;
$subgroup = $this->options['subgroup'];
$clients = Client::scope()
->orderBy('name')
@ -56,6 +57,13 @@ class AgingReport extends AbstractReport
//$this->addToTotals($client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);
//$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
//$this->addToTotals($client->currency_id, 'balance', $invoice->balance);
if ($subgroup == 'age') {
$dimension = trans('texts.' .$invoice->present()->ageGroup);
} else {
$dimension = $this->getDimension($client);
}
$this->addChartData($dimension, $invoice->invoice_date, $invoice->balance);
}
}
}

View File

@ -35,6 +35,7 @@ class ClientReport extends AbstractReport
public function run()
{
$account = Auth::user()->account;
$subgroup = $this->options['subgroup'];
$clients = Client::scope()
->orderBy('name')
@ -55,6 +56,13 @@ class ClientReport extends AbstractReport
foreach ($client->invoices as $invoice) {
$amount += $invoice->amount;
$paid += $invoice->getAmountPaid();
if ($subgroup == 'country') {
$dimension = $client->present()->country;
} else {
$dimension = $this->getDimension($client);
}
$this->addChartData($dimension, $invoice->invoice_date, $invoice->amount);
}
$row = [

View File

@ -22,11 +22,12 @@ class CreditReport extends AbstractReport
public function run()
{
$account = Auth::user()->account;
$subgroup = $this->options['subgroup'];
$clients = Client::scope()
->orderBy('name')
->withArchived()
->with(['user', 'credits' => function ($query) {
->with(['contacts', 'user', 'credits' => function ($query) {
$query->where('credit_date', '>=', $this->startDate)
->where('credit_date', '<=', $this->endDate)
->withArchived();
@ -39,6 +40,9 @@ class CreditReport extends AbstractReport
foreach ($client->credits as $credit) {
$amount += $credit->amount;
$balance += $credit->balance;
$dimension = $this->getDimension($client);
$this->addChartData($dimension, $credit->credit_date, $credit->amount);
}
if (! $amount && ! $balance) {

View File

@ -24,6 +24,7 @@ class DocumentReport extends AbstractReport
$account = auth()->user()->account;
$filter = $this->options['document_filter'];
$exportFormat = $this->options['export_format'];
$subgroup = $this->options['subgroup'];
$records = false;
if (! $filter || $filter == ENTITY_INVOICE) {
@ -70,12 +71,16 @@ class DocumentReport extends AbstractReport
foreach ($records as $record) {
foreach ($record->documents as $document) {
$date = $record->getEntityType() == ENTITY_INVOICE ? $record->invoice_date : $record->expense_date;
$this->data[] = [
$this->isExport ? $document->name : link_to($document->getUrl(), $document->name),
$record->client ? ($this->isExport ? $record->client->getDisplayName() : $record->client->present()->link) : '',
$this->isExport ? $record->present()->titledName : ($filter ? $record->present()->link : link_to($record->present()->url, $record->present()->titledName)),
$record->getEntityType() == ENTITY_INVOICE ? $record->invoice_date : $record->expense_date,
$date,
];
$dimension = $this->getDimension($record);
$this->addChartData($dimension, $date, 1);
}
}
}

View File

@ -27,6 +27,10 @@ class ExpenseReport extends AbstractReport
$columns['tax'] = ['columnSelector-false'];
}
if ($this->isExport) {
$columns['currency'] = ['columnSelector-false'];
}
return $columns;
}
@ -34,6 +38,7 @@ class ExpenseReport extends AbstractReport
{
$account = Auth::user()->account;
$exportFormat = $this->options['export_format'];
$subgroup = $this->options['subgroup'];
$with = ['client.contacts', 'vendor'];
$hasTaxRates = TaxRate::scope()->count();
@ -84,10 +89,24 @@ class ExpenseReport extends AbstractReport
$row[] = $expense->present()->taxAmount;
}
if ($this->isExport) {
$row[] = $expense->present()->currencyCode;
}
$this->data[] = $row;
$this->addToTotals($expense->expense_currency_id, 'amount', $amount);
$this->addToTotals($expense->invoice_currency_id, 'amount', 0);
if ($subgroup == 'category') {
$dimension = $expense->present()->category;
} elseif ($subgroup == 'vendor') {
$dimension = $expense->vendor ? $expense->vendor->name : trans('texts.unset');
} else {
$dimension = $this->getDimension($expense);
}
$this->addChartData($dimension, $expense->expense_date, $amount);
}
}
}

View File

@ -46,6 +46,7 @@ class InvoiceReport extends AbstractReport
$account = Auth::user()->account;
$statusIds = $this->options['status_ids'];
$exportFormat = $this->options['export_format'];
$subgroup = $this->options['subgroup'];
$hasTaxRates = TaxRate::scope()->count();
$clients = Client::scope()
@ -122,6 +123,14 @@ class InvoiceReport extends AbstractReport
$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
$this->addToTotals($client->currency_id, 'balance', $invoice->balance);
if ($subgroup == 'status') {
$dimension = $invoice->statusLabel();
} else {
$dimension = $this->getDimension($client);
}
$this->addChartData($dimension, $invoice->invoice_date, $invoice->amount);
}
}
}

View File

@ -28,6 +28,7 @@ class PaymentReport extends AbstractReport
$account = Auth::user()->account;
$currencyType = $this->options['currency_type'];
$invoiceMap = [];
$subgroup = $this->options['subgroup'];
$payments = Payment::scope()
->orderBy('payment_date', 'desc')
@ -80,6 +81,15 @@ class PaymentReport extends AbstractReport
}
}
if ($subgroup == 'method') {
$dimension = $payment->present()->method;
} else {
$dimension = $this->getDimension($payment);
}
$convertedAmount = $currencyType == 'converted' ? ($invoice->amount * $payment->exchange_rate) : $invoice->amount;
$this->addChartData($dimension, $payment->payment_date, $convertedAmount);
$lastInvoiceId = $invoice->id;
}
}

View File

@ -46,11 +46,12 @@ class ProductReport extends AbstractReport
{
$account = Auth::user()->account;
$statusIds = $this->options['status_ids'];
$subgroup = $this->options['subgroup'];
$clients = Client::scope()
->orderBy('name')
->withArchived()
->with('contacts')
->with('contacts', 'user')
->with(['invoices' => function ($query) use ($statusIds) {
$query->invoices()
->withArchived()
@ -90,6 +91,13 @@ class ProductReport extends AbstractReport
$this->data[] = $row;
if ($subgroup == 'product') {
$dimension = $item->product_key;
} else {
$dimension = $this->getDimension($client);
}
$this->addChartData($dimension, $invoice->invoice_date, $invoice->amount);
}
//$this->addToTotals($client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);

View File

@ -22,10 +22,11 @@ class ProfitAndLossReport extends AbstractReport
public function run()
{
$account = Auth::user()->account;
$subgroup = $this->options['subgroup'];
$payments = Payment::scope()
->orderBy('payment_date', 'desc')
->with('client.contacts', 'invoice')
->with('client.contacts', 'invoice', 'user')
->withArchived()
->excludeFailed()
->where('payment_date', '>=', $this->startDate)
@ -48,6 +49,13 @@ class ProfitAndLossReport extends AbstractReport
$this->addToTotals($client->currency_id, 'revenue', $payment->getCompletedAmount(), $payment->present()->month);
$this->addToTotals($client->currency_id, 'expenses', 0, $payment->present()->month);
$this->addToTotals($client->currency_id, 'profit', $payment->getCompletedAmount(), $payment->present()->month);
if ($subgroup == 'type') {
$dimension = trans('texts.payment');
} else {
$dimension = $this->getDimension($payment);
}
$this->addChartData($dimension, $payment->payment_date, $payment->getCompletedAmount());
}
$expenses = Expense::scope()
@ -70,6 +78,13 @@ class ProfitAndLossReport extends AbstractReport
$this->addToTotals($expense->expense_currency_id, 'revenue', 0, $expense->present()->month);
$this->addToTotals($expense->expense_currency_id, 'expenses', $expense->amountWithTax(), $expense->present()->month);
$this->addToTotals($expense->expense_currency_id, 'profit', $expense->amountWithTax() * -1, $expense->present()->month);
if ($subgroup == 'type') {
$dimension = trans('texts.expense');
} else {
$dimension = $this->getDimension($expense);
}
$this->addChartData($dimension, $expense->expense_date, $expense->amountWithTax());
}
//$this->addToTotals($client->currency_id, 'paid', $payment ? $payment->getCompletedAmount() : 0);

View File

@ -43,6 +43,7 @@ class QuoteReport extends AbstractReport
$statusIds = $this->options['status_ids'];
$exportFormat = $this->options['export_format'];
$hasTaxRates = TaxRate::scope()->count();
$subgroup = $this->options['subgroup'];
$clients = Client::scope()
->orderBy('name')
@ -102,6 +103,14 @@ class QuoteReport extends AbstractReport
$this->data[] = $row;
$this->addToTotals($client->currency_id, 'amount', $invoice->amount);
if ($subgroup == 'status') {
$dimension = $invoice->statusLabel();
} else {
$dimension = $this->getDimension($client);
}
$this->addChartData($dimension, $invoice->invoice_date, $invoice->amount);
}
}
}

View File

@ -24,6 +24,7 @@ class TaskReport extends AbstractReport
{
$startDate = date_create($this->startDate);
$endDate = date_create($this->endDate);
$subgroup = $this->options['subgroup'];
$tasks = Task::scope()
->orderBy('created_at', 'desc')
@ -52,6 +53,13 @@ class TaskReport extends AbstractReport
$this->addToTotals($currencyId, 'duration', $duration);
$this->addToTotals($currencyId, 'amount', $amount);
if ($subgroup == 'project') {
$dimension = $task->present()->project;
} else {
$dimension = $this->getDimension($task);
}
$this->addChartData($dimension, $task->created_at, round($duration / 60 / 60, 2));
}
}
}

View File

@ -24,11 +24,12 @@ class TaxRateReport extends AbstractReport
public function run()
{
$account = Auth::user()->account;
$subgroup = $this->options['subgroup'];
$clients = Client::scope()
->orderBy('name')
->withArchived()
->with('contacts')
->with('contacts', 'user')
->with(['invoices' => function ($query) {
$query->with('invoice_items')
->withArchived()
@ -85,6 +86,9 @@ class TaxRateReport extends AbstractReport
$this->addToTotals($client->currency_id, 'amount', $tax['amount']);
$this->addToTotals($client->currency_id, 'paid', $tax['paid']);
$dimension = $this->getDimension($client);
$this->addChartData($dimension, $invoice->invoice_date, $tax['amount']);
}
}
}

View File

@ -193,7 +193,7 @@ class AccountRepository
foreach ($clients as $client) {
if ($client->name) {
$data['clients'][] = [
'value' => ($account->clientNumbersEnabled() && $client->id_number ? $client->id_number . ': ' : '') . $client->name,
'value' => ($client->id_number ? $client->id_number . ': ' : '') . $client->name,
'tokens' => implode(',', [$client->name, $client->id_number, $client->vat_number, $client->work_phone]),
'url' => $client->present()->url,
];

View File

@ -30,7 +30,7 @@ class ActivityRepository
$activity->activity_type_id = $activityTypeId;
$activity->adjustment = $balanceChange;
$activity->client_id = $client ? $client->id : 0;
$activity->client_id = $client ? $client->id : null;
$activity->balance = $client ? ($client->balance + $balanceChange) : 0;
$activity->notes = $notes ?: '';

View File

@ -2,6 +2,7 @@
namespace App\Ninja\Repositories;
use App\Jobs\PurgeClientData;
use App\Events\ClientWasCreated;
use App\Events\ClientWasUpdated;
use App\Models\Client;
@ -75,6 +76,11 @@ class ClientRepository extends BaseRepository
return $query;
}
public function purge($client)
{
dispatch(new PurgeClientData($client));
}
public function save($data, $client = null)
{
$publicId = isset($data['public_id']) ? $data['public_id'] : false;

View File

@ -54,8 +54,8 @@ class ExpenseRepository extends BaseRepository
->leftJoin('expense_categories', 'expenses.expense_category_id', '=', 'expense_categories.id')
->where('expenses.account_id', '=', $accountid)
->where('contacts.deleted_at', '=', null)
->where('vendors.deleted_at', '=', null)
->where('clients.deleted_at', '=', null)
//->where('vendors.deleted_at', '=', null)
//->where('clients.deleted_at', '=', null)
->where(function ($query) { // handle when client isn't set
$query->where('contacts.is_primary', '=', true)
->orWhere('contacts.is_primary', '=', null);

View File

@ -16,6 +16,7 @@ class NinjaRepository
$company = $account->company;
$company->fill($data);
$company->plan_expires = $company->plan_expires ?: null;
$company->save();
}
}

View File

@ -237,6 +237,9 @@ class AccountTransformer extends EntityTransformer
'header_font_id' => (int) $account->header_font_id,
'body_font_id' => (int) $account->body_font_id,
'auto_convert_quote' => (bool) $account->auto_convert_quote,
'auto_archive_quote' => (bool) $account->auto_archive_quote,
'auto_archive_invoice' => (bool) $account->auto_archive_invoice,
'auto_email_invoice' => (bool) $account->auto_email_invoice,
'all_pages_footer' => (bool) $account->all_pages_footer,
'all_pages_header' => (bool) $account->all_pages_header,
'show_currency_code' => (bool) $account->show_currency_code,
@ -272,6 +275,7 @@ class AccountTransformer extends EntityTransformer
'reset_counter_frequency_id' => (int) $account->reset_counter_frequency_id,
'payment_type_id' => (int) $account->payment_type_id,
'gateway_fee_enabled' => (bool) $account->gateway_fee_enabled,
'send_item_details' => (bool) $account->send_item_details,
'reset_counter_date' => $account->reset_counter_date,
'custom_contact_label1' => $account->custom_contact_label1,
'custom_contact_label2' => $account->custom_contact_label2,

View File

@ -0,0 +1,76 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\SlackMessage;
class PaymentCreated extends Notification implements ShouldQueue
{
use Queueable;
protected $payment;
protected $invoice;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct($payment, $invoice)
{
$this->invoice = $invoice;
$this->payment = $payment;
}
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
return ['slack'];
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toSlack($notifiable)
{
$url = 'http://www.ninja.test/subscriptions/create';
return (new SlackMessage)
->from(APP_NAME)
->image('https://app.invoiceninja.com/favicon-v2.png')
->content(trans('texts.received_new_payment'))
->attachment(function ($attachment) {
$invoiceName = $this->invoice->present()->titledName;
$invoiceLink = $this->invoice->present()->multiAccountLink;
$attachment->title($invoiceName, $invoiceLink)
->fields([
trans('texts.client') => $this->invoice->client->getDisplayName(),
trans('texts.amount') => $this->payment->present()->amount,
]);
});
}
/**
* Get the array representation of the notification.
*
* @param mixed $notifiable
* @return array
*/
public function toArray($notifiable)
{
return [
//
];
}
}

View File

@ -27,6 +27,7 @@ class EventServiceProvider extends ServiceProvider
'App\Events\ClientWasDeleted' => [
'App\Listeners\ActivityListener@deletedClient',
'App\Listeners\SubscriptionListener@deletedClient',
'App\Listeners\HistoryListener@deletedClient',
],
'App\Events\ClientWasRestored' => [
'App\Listeners\ActivityListener@restoredClient',
@ -54,6 +55,7 @@ class EventServiceProvider extends ServiceProvider
'App\Listeners\ActivityListener@deletedInvoice',
'App\Listeners\TaskListener@deletedInvoice',
'App\Listeners\ExpenseListener@deletedInvoice',
'App\Listeners\HistoryListener@deletedInvoice',
],
'App\Events\InvoiceWasRestored' => [
'App\Listeners\ActivityListener@restoredInvoice',
@ -89,6 +91,7 @@ class EventServiceProvider extends ServiceProvider
],
'App\Events\QuoteWasDeleted' => [
'App\Listeners\ActivityListener@deletedQuote',
'App\Listeners\HistoryListener@deletedQuote',
],
'App\Events\QuoteWasRestored' => [
'App\Listeners\ActivityListener@restoredQuote',
@ -188,6 +191,7 @@ class EventServiceProvider extends ServiceProvider
'App\Events\TaskWasDeleted' => [
'App\Listeners\ActivityListener@deletedTask',
'App\Listeners\SubscriptionListener@deletedTask',
'App\Listeners\HistoryListener@deletedTask',
],
// Vendor events
@ -219,6 +223,17 @@ class EventServiceProvider extends ServiceProvider
'App\Events\ExpenseWasDeleted' => [
'App\Listeners\ActivityListener@deletedExpense',
'App\Listeners\SubscriptionListener@deletedExpense',
'App\Listeners\HistoryListener@deletedExpense',
],
// Project events
'App\Events\ProjectWasDeleted' => [
'App\Listeners\HistoryListener@deletedProject',
],
// Proposal events
'App\Events\ProposalWasDeleted' => [
'App\Listeners\HistoryListener@deletedProposal',
],
'Illuminate\Queue\Events\JobExceptionOccurred' => [

View File

@ -393,12 +393,14 @@ class ImportService
}
}
/*
// if the invoice number is blank we'll assign it
if ($entityType == ENTITY_INVOICE && ! $data['invoice_number']) {
$account = Auth::user()->account;
$invoice = Invoice::createNew();
$data['invoice_number'] = $account->getNextNumber($invoice);
}
*/
if (EntityModel::validate($data, $entityType) !== true) {
return false;

View File

@ -110,7 +110,14 @@ class InvoiceService extends BaseService
*/
public function convertQuote($quote)
{
return $this->invoiceRepo->cloneInvoice($quote, $quote->id);
$account = $quote->account;
$invoice = $this->invoiceRepo->cloneInvoice($quote, $quote->id);
if ($account->auto_archive_quote) {
$this->invoiceRepo->archive($quote);
}
return $invoice;
}
/**
@ -141,6 +148,10 @@ class InvoiceService extends BaseService
$quote->markApproved();
}
if ($account->auto_archive_quote) {
$this->invoiceRepo->archive($quote);
}
return $invitation->invitation_key;
}

View File

@ -137,9 +137,11 @@ class PaymentService extends BaseService
try {
return $paymentDriver->completeOnsitePurchase(false, $paymentMethod);
} catch (Exception $exception) {
if (! Auth::check()) {
$subject = trans('texts.auto_bill_failed', ['invoice_number' => $invoice->invoice_number]);
$message = sprintf('%s: %s', ucwords($paymentDriver->providerName()), $exception->getMessage());
//$message .= $exception->getTraceAsString();
Utils::logError($message, 'PHP', true);
if (! Auth::check()) {
$mailer = app('App\Ninja\Mailers\UserMailer');
$mailer->sendMessage($invoice->user, $subject, $message, [
'invoice' => $invoice

Some files were not shown because too many files have changed in this diff Show More