Merge pull request #8119 from turbo124/v5-develop

Product Sales Report
This commit is contained in:
David Bomba 2023-01-09 11:06:54 +11:00 committed by GitHub
commit 7372cf4ffc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1020 additions and 51 deletions

View File

@ -1 +1 @@
5.5.50 5.5.51

View File

@ -22,6 +22,7 @@ use App\Jobs\Ninja\CompanySizeCheck;
use App\Jobs\Ninja\QueueSize; use App\Jobs\Ninja\QueueSize;
use App\Jobs\Ninja\SystemMaintenance; use App\Jobs\Ninja\SystemMaintenance;
use App\Jobs\Ninja\TaskScheduler; use App\Jobs\Ninja\TaskScheduler;
use App\Jobs\Invoice\InvoiceCheckLateWebhook;
use App\Jobs\Quote\QuoteCheckExpired; use App\Jobs\Quote\QuoteCheckExpired;
use App\Jobs\Subscription\CleanStaleInvoiceOrder; use App\Jobs\Subscription\CleanStaleInvoiceOrder;
use App\Jobs\Util\DiskCleanup; use App\Jobs\Util\DiskCleanup;
@ -78,6 +79,9 @@ class Kernel extends ConsoleKernel
/* Fires notifications for expired Quotes */ /* Fires notifications for expired Quotes */
$schedule->job(new QuoteCheckExpired)->dailyAt('05:10')->withoutOverlapping()->name('quote-expired-job')->onOneServer(); $schedule->job(new QuoteCheckExpired)->dailyAt('05:10')->withoutOverlapping()->name('quote-expired-job')->onOneServer();
/* Fires webhooks for overdue Invoice */
$schedule->job(new InvoiceCheckLateWebhook)->dailyAt('07:00')->withoutOverlapping()->name('invoice-overdue-job')->onOneServer();
/* Performs auto billing */ /* Performs auto billing */
$schedule->job(new AutoBillCron)->dailyAt('06:20')->withoutOverlapping()->name('auto-bill-job')->onOneServer(); $schedule->job(new AutoBillCron)->dailyAt('06:20')->withoutOverlapping()->name('auto-bill-job')->onOneServer();

View File

@ -0,0 +1,138 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Export\CSV;
use App\Libraries\MultiDB;
use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\Document;
use App\Models\Invoice;
use App\Models\Product;
use App\Transformers\ProductTransformer;
use App\Utils\Ninja;
use Illuminate\Support\Facades\App;
use League\Csv\Writer;
use Illuminate\Support\Carbon;
class ProductSalesExport extends BaseExport
{
private Company $company;
protected array $input;
protected $date_key = 'created_at';
protected array $entity_keys = [
'custom_value1' => 'custom_value1',
'custom_value2' => 'custom_value2',
'custom_value3' => 'custom_value3',
'custom_value4' => 'custom_value4',
'product_key' => 'product_key',
'notes' => 'notes',
'cost' => 'cost',
'price' => 'price',
'quantity' => 'quantity',
'tax_rate1' => 'tax_rate1',
'tax_rate2' => 'tax_rate2',
'tax_rate3' => 'tax_rate3',
'tax_name1' => 'tax_name1',
'tax_name2' => 'tax_name2',
'tax_name3' => 'tax_name3',
'is_amount_discount' => 'is_amount_discount',
'discount' => 'discount',
'line_total' => 'line_total',
'gross_line_total' => 'gross_line_total',
'status' => 'status',
'date' => 'date',
'currency' => 'currency',
'client' => 'client',
];
private array $decorate_keys = [
'client',
'currency',
'date',
];
public function __construct(Company $company, array $input)
{
$this->company = $company;
$this->input = $input;
}
public function run()
{
MultiDB::setDb($this->company->db);
App::forgetInstance('translator');
App::setLocale($this->company->locale());
$t = app('translator');
$t->replace(Ninja::transformTranslations($this->company->settings));
//load the CSV document from a string
$this->csv = Writer::createFromString();
if (count($this->input['report_keys']) == 0) {
$this->input['report_keys'] = array_values($this->entity_keys);
}
//insert the header
$this->csv->insertOne($this->buildHeader());
$query = Invoice::query()
->withTrashed()
->where('company_id', $this->company->id)
->where('is_deleted', 0)
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL, Invoice::STATUS_PAID]);
$query = $this->addDateRange($query);
$query->cursor()
->each(function ($invoice) {
foreach($invoice->line_items as $item)
$this->csv->insertOne($this->buildRow($invoice, $item));
});
return $this->csv->toString();
}
private function buildRow($invoice, $invoice_item) :array
{
$transformed_entity = (array)$invoice_item;
$entity = [];
foreach (array_values($this->input['report_keys']) as $key) {
$keyval = array_search($key, $this->entity_keys);
if (array_key_exists($key, $transformed_entity)) {
$entity[$keyval] = $transformed_entity[$key];
} else {
$entity[$keyval] = '';
}
}
return $this->decorateAdvancedFields($invoice, $entity);
}
private function decorateAdvancedFields(Invoice $invoice, $entity) :array
{
$entity['client'] = $invoice->client->present()->name();
$entity['currency'] = $invoice->client->currency()->code;
$entity['status'] = $invoice->stringStatus($invoice->status_id);
$entity['date'] = Carbon::parse($invoice->date)->format($this->company->date_format());
return $entity;
}
}

View File

@ -69,25 +69,54 @@ class ExpenseFilters extends QueryFilters
return $this->builder; return $this->builder;
} }
if (in_array('logged', $status_parameters)) { $this->builder->whereNested(function ($query) use($status_parameters){
$this->builder->where('amount', '>', 0);
}
if (in_array('pending', $status_parameters)) { if (in_array('logged', $status_parameters)) {
$this->builder->whereNull('invoice_id')->whereNotNull('payment_date');
}
if (in_array('invoiced', $status_parameters)) { $query->orWhere(function ($query){
$this->builder->whereNotNull('invoice_id'); $query->where('amount', '>', 0)
} ->whereNull('invoice_id')
->whereNull('payment_date');
});
}
if (in_array('paid', $status_parameters)) { if (in_array('pending', $status_parameters)) {
$this->builder->whereNotNull('payment_date');
}
if (in_array('unpaid', $status_parameters)) { $query->orWhere(function ($query){
$this->builder->whereNull('payment_date'); $query->where('should_be_invoiced',true)
} ->whereNull('invoice_id');
});
}
if (in_array('invoiced', $status_parameters)) {
$query->orWhere(function ($query){
$query->whereNotNull('invoice_id');
});
}
if (in_array('paid', $status_parameters)) {
$query->orWhere(function ($query){
$query->whereNotNull('payment_date');
});
}
if (in_array('unpaid', $status_parameters)) {
$query->orWhere(function ($query){
$query->whereNull('payment_date');
});
}
});
// nlog($this->builder->toSql());
return $this->builder; return $this->builder;
} }
@ -212,8 +241,6 @@ class ExpenseFilters extends QueryFilters
*/ */
public function entityFilter() public function entityFilter()
{ {
//return $this->builder->whereCompanyId(auth()->user()->company()->id);
return $this->builder->company(); return $this->builder->company();
} }
} }

View File

@ -218,6 +218,15 @@ abstract class QueryFilters
return $this->builder->where('client_id', $this->decodePrimaryKey($client_id)); return $this->builder->where('client_id', $this->decodePrimaryKey($client_id));
} }
public function vendor_id(string $vendor_id = '') :Builder
{
if (strlen($vendor_id) == 0) {
return $this->builder;
}
return $this->builder->where('vendor_id', $this->decodePrimaryKey($vendor_id));
}
public function filter_deleted_clients($value) public function filter_deleted_clients($value)
{ {
if ($value == 'true') { if ($value == 'true') {

View File

@ -85,14 +85,19 @@ class QuoteFilters extends QueryFilters
} }
if (in_array('expired', $status_parameters)) { if (in_array('expired', $status_parameters)) {
$this->builder->orWhere('status_id', Quote::STATUS_SENT) $this->builder->orWhere(function ($query){
$query->where('status_id', Quote::STATUS_SENT)
->whereNotNull('due_date')
->where('due_date', '<=', now()->toDateString()); ->where('due_date', '<=', now()->toDateString());
});
} }
if (in_array('upcoming', $status_parameters)) { if (in_array('upcoming', $status_parameters)) {
$this->builder->orWhere('status_id', Quote::STATUS_SENT) $this->builder->orWhere(function ($query){
$query->where('status_id', Quote::STATUS_SENT)
->where('due_date', '>=', now()->toDateString()) ->where('due_date', '>=', now()->toDateString())
->orderBy('due_date', 'DESC'); ->orderBy('due_date', 'DESC');
});
} }
return $this->builder; return $this->builder;

View File

@ -0,0 +1,129 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Filters;
use App\Models\User;
use App\Models\Webhook;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
/**
* SubscriptionFilters.
*/
class SubscriptionFilters extends QueryFilters
{
/**
* Filter based on search text.
*
* @param string query filter
* @return Builder
* @deprecated
*/
public function filter(string $filter = '') : Builder
{
if (strlen($filter) == 0) {
return $this->builder;
}
return $this->builder->where(function ($query) use ($filter) {
$query->where('name', 'like', '%'.$filter.'%');
});
}
/**
* Filters the list based on the status
* archived, active, deleted.
*
* @param string filter
* @return Builder
*/
public function status(string $filter = '') : Builder
{
if (strlen($filter) == 0) {
return $this->builder;
}
$table = 'subscriptions';
$filters = explode(',', $filter);
return $this->builder->where(function ($query) use ($filters, $table) {
$query->whereNull($table.'.id');
if (in_array(parent::STATUS_ACTIVE, $filters)) {
$query->orWhereNull($table.'.deleted_at');
}
if (in_array(parent::STATUS_ARCHIVED, $filters)) {
$query->orWhere(function ($query) use ($table) {
$query->whereNotNull($table.'.deleted_at');
if (! in_array($table, ['users'])) {
$query->where($table.'.is_deleted', '=', 0);
}
});
}
if (in_array(parent::STATUS_DELETED, $filters)) {
$query->orWhere($table.'.is_deleted', '=', 1);
}
});
}
/**
* Sorts the list based on $sort.
*
* @param string sort formatted as column|asc
* @return Builder
*/
public function sort(string $sort) : Builder
{
$sort_col = explode('|', $sort);
return $this->builder->orderBy($sort_col[0], $sort_col[1]);
}
/**
* Returns the base query.
*
* @param int company_id
* @param User $user
* @return Builder
* @deprecated
*/
public function baseQuery(int $company_id, User $user) : Builder
{
$query = DB::table('subscriptions')
->join('companies', 'companies.id', '=', 'subscriptions.company_id')
->where('subscriptions.company_id', '=', $company_id);
/*
* If the user does not have permissions to view all invoices
* limit the user to only the invoices they have created
*/
if (Gate::denies('view-list', Webhook::class)) {
$query->where('subscriptions.user_id', '=', $user->id);
}
return $query;
}
/**
* Filters the query by the users company ID.
*
* @return Illuminate\Database\Query\Builder
*/
public function entityFilter()
{
return $this->builder->company();
}
}

View File

@ -112,6 +112,7 @@ class ActivityController extends BaseController
'purchase_order' => $activity->purchase_order ? $activity->purchase_order : '', 'purchase_order' => $activity->purchase_order ? $activity->purchase_order : '',
'subscription' => $activity->subscription ? $activity->subscription : '', 'subscription' => $activity->subscription ? $activity->subscription : '',
'vendor_contact' => $activity->vendor_contact ? $activity->vendor_contact : '', 'vendor_contact' => $activity->vendor_contact ? $activity->vendor_contact : '',
'recurring_expense' => $activity->recurring_expense ? $activity->recurring_expense : '',
]; ];
return array_merge($arr, $activity->toArray()); return array_merge($arr, $activity->toArray());

View File

@ -0,0 +1,89 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers\Reports;
use App\Export\CSV\ProductExport;
use App\Export\CSV\ProductSalesExport;
use App\Http\Controllers\BaseController;
use App\Http\Requests\Report\GenericReportRequest;
use App\Http\Requests\Report\ProductSalesReportRequest;
use App\Jobs\Report\SendToAdmin;
use App\Models\Client;
use App\Utils\Traits\MakesHash;
use Illuminate\Http\Response;
class ProductSalesReportController extends BaseController
{
use MakesHash;
private string $filename = 'product_sales.csv';
public function __construct()
{
parent::__construct();
}
/**
* @OA\Post(
* path="/api/v1/reports/product_sales",
* operationId="getProductSalesReport",
* tags={"reports"},
* summary="Product Salesreports",
* description="Export product sales reports",
* @OA\Parameter(ref="#/components/parameters/X-Api-Secret"),
* @OA\Parameter(ref="#/components/parameters/X-Requested-With"),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(ref="#/components/schemas/GenericReportSchema")
* ),
* @OA\Response(
* response=200,
* description="success",
* @OA\Header(header="X-MINIMUM-CLIENT-VERSION", ref="#/components/headers/X-MINIMUM-CLIENT-VERSION"),
* @OA\Header(header="X-RateLimit-Remaining", ref="#/components/headers/X-RateLimit-Remaining"),
* @OA\Header(header="X-RateLimit-Limit", ref="#/components/headers/X-RateLimit-Limit"),
* ),
* @OA\Response(
* response=422,
* description="Validation error",
* @OA\JsonContent(ref="#/components/schemas/ValidationError"),
* ),
* @OA\Response(
* response="default",
* description="Unexpected Error",
* @OA\JsonContent(ref="#/components/schemas/Error"),
* ),
* )
*/
public function __invoke(ProductSalesReportRequest $request)
{
if ($request->has('send_email') && $request->get('send_email')) {
SendToAdmin::dispatch(auth()->user()->company(), $request->all(), ProductSalesExport::class, $this->filename);
return response()->json(['message' => 'working...'], 200);
}
// expect a list of visible fields, or use the default
$export = new ProductSalesExport(auth()->user()->company(), $request->all());
$csv = $export->run();
$headers = [
'Content-Disposition' => 'attachment',
'Content-Type' => 'text/csv',
];
return response()->streamDownload(function () use ($csv) {
echo $csv;
}, $this->filename, $headers);
}
}

View File

@ -15,6 +15,7 @@ namespace App\Http\Controllers;
use App\Events\Subscription\SubscriptionWasCreated; use App\Events\Subscription\SubscriptionWasCreated;
use App\Events\Subscription\SubscriptionWasUpdated; use App\Events\Subscription\SubscriptionWasUpdated;
use App\Factory\SubscriptionFactory; use App\Factory\SubscriptionFactory;
use App\Filters\SubscriptionFilters;
use App\Http\Requests\Subscription\CreateSubscriptionRequest; use App\Http\Requests\Subscription\CreateSubscriptionRequest;
use App\Http\Requests\Subscription\DestroySubscriptionRequest; use App\Http\Requests\Subscription\DestroySubscriptionRequest;
use App\Http\Requests\Subscription\EditSubscriptionRequest; use App\Http\Requests\Subscription\EditSubscriptionRequest;
@ -80,9 +81,9 @@ class SubscriptionController extends BaseController
* ), * ),
* ) * )
*/ */
public function index(): \Illuminate\Http\Response public function index(SubscriptionFilters $filters): \Illuminate\Http\Response
{ {
$subscriptions = Subscription::query()->company(); $subscriptions = Subscription::filter($filters);
return $this->listResponse($subscriptions); return $this->listResponse($subscriptions);
} }

View File

@ -0,0 +1,71 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Requests\Report;
use App\Http\Requests\Request;
use App\Utils\Traits\MakesHash;
use Illuminate\Validation\Rule;
class ProductSalesReportRequest extends Request
{
use MakesHash;
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize() : bool
{
return auth()->user()->isAdmin();
}
public function rules()
{
return [
'date_range' => 'bail|required|string',
'end_date' => 'bail|required_if:date_range,custom|nullable|date',
'start_date' => 'bail|required_if:date_range,custom|nullable|date',
'report_keys' => 'bail|present|array',
'send_email' => 'bail|required|bool',
'client_id' => 'bail|sometimes|exists:clients,id,company_id,'.auth()->user()->company()->id.',is_deleted,0',
];
}
public function prepareForValidation()
{
$input = $this->all();
if (! array_key_exists('date_range', $input)) {
$input['date_range'] = 'all';
}
if (! array_key_exists('report_keys', $input)) {
$input['report_keys'] = [];
}
if (! array_key_exists('send_email', $input)) {
$input['send_email'] = true;
}
if (array_key_exists('date_range', $input) && $input['date_range'] != 'custom') {
$input['start_date'] = null;
$input['end_date'] = null;
}
if(array_key_exists('client_id', $input) && strlen($input['client_id']) >=1)
$input['client_id'] = $this->decodePrimaryKey($input['client_id']);
$this->replace($input);
}
}

View File

@ -61,9 +61,9 @@ class InvoiceTransformer extends BaseTransformer
'discount' => $this->getFreshbookQuantityFloat($record, 'Discount Percentage'), 'discount' => $this->getFreshbookQuantityFloat($record, 'Discount Percentage'),
'is_amount_discount' => false, 'is_amount_discount' => false,
'tax_name1' => $this->getString($record, 'Tax 1 Type'), 'tax_name1' => $this->getString($record, 'Tax 1 Type'),
'tax_rate1' => $this->getFreshbookQuantityFloat($record, 'Tax 1 Amount'), 'tax_rate1' => $this->calcTaxRate($record, 'Tax 1 Amount'),
'tax_name2' => $this->getString($record, 'Tax 2 Type'), 'tax_name2' => $this->getString($record, 'Tax 2 Type'),
'tax_rate2' => $this->getFreshbookQuantityFloat($record, 'Tax 2 Amount'), 'tax_rate2' => $this->calcTaxRate($record, 'Tax 2 Amount'),
]; ];
$transformed['amount'] += $this->getFreshbookQuantityFloat($record, 'Line Total'); $transformed['amount'] += $this->getFreshbookQuantityFloat($record, 'Line Total');
} }
@ -79,6 +79,27 @@ class InvoiceTransformer extends BaseTransformer
return $transformed; return $transformed;
} }
//Line Subtotal
public function calcTaxRate($record, $field)
{
if(isset($record['Line Subtotal']) && $record['Line Subtotal'] > 0)
return ($record[$field] / $record['Line Subtotal']) * 100;
$tax_amount1 = isset($record['Tax 1 Amount']) ? $record['Tax 1 Amount'] : 0;
$tax_amount2 = isset($record['Tax 2 Amount']) ? $record['Tax 2 Amount'] : 0;
$line_total = isset($record['Line Total']) ? $record['Line Total'] : 0;
$subtotal = $line_total - $tax_amount2 - $tax_amount1;
if($subtotal > 0)
return $record[$field] / $subtotal * 100;
return 0;
}
/** @return float */ /** @return float */
public function getFreshbookQuantityFloat($data, $field) public function getFreshbookQuantityFloat($data, $field)
{ {

View File

@ -1112,6 +1112,9 @@ class CompanyImport implements ShouldQueue
foreach((object)$this->getObject("documents") as $document) foreach((object)$this->getObject("documents") as $document)
{ {
//todo enable this for v5.5.51
if(!$this->transformDocumentId($document->documentable_id, $document->documentable_type))
continue;
$new_document = new Document(); $new_document = new Document();
$new_document->user_id = $this->transformId('users', $document->user_id); $new_document->user_id = $this->transformId('users', $document->user_id);
@ -1152,6 +1155,10 @@ class CompanyImport implements ShouldQueue
{ {
try{ try{
Storage::disk(config('filesystems.default'))->put($new_document->url, $file); Storage::disk(config('filesystems.default'))->put($new_document->url, $file);
$new_document->disk = config('filesystems.default');
$new_document->save();
} }
catch(\Exception $e) catch(\Exception $e)
{ {
@ -1279,6 +1286,9 @@ class CompanyImport implements ShouldQueue
case Payment::class: case Payment::class:
return $this->transformId('payments', $id); return $this->transformId('payments', $id);
break; break;
case Project::class:
return $this->transformId('projects', $id);
break;
case Product::class: case Product::class:
return $this->transformId('products', $id); return $this->transformId('products', $id);
break; break;
@ -1294,7 +1304,7 @@ class CompanyImport implements ShouldQueue
default: default:
# code... return false;
break; break;
} }
} }

View File

@ -106,6 +106,24 @@ class CSVIngest implements ShouldQueue
$new_contact->is_primary = true; $new_contact->is_primary = true;
$new_contact->save(); $new_contact->save();
} }
Client::with('contacts')->where('company_id', $this->company->id)->cursor()->each(function ($client){
$contact = $client->contacts()->first();
$contact->is_primary = true;
$contact->save();
});
Vendor::with('contacts')->where('company_id', $this->company->id)->cursor()->each(function ($vendor){
$contact = $vendor->contacts()->first();
$contact->is_primary = true;
$contact->save();
});
} }
private function bootEngine() private function bootEngine()

View File

@ -0,0 +1,111 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Jobs\Invoice;
use App\Jobs\Util\WebhookHandler;
use App\Libraries\MultiDB;
use App\Models\Invoice;
use App\Models\Webhook;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class InvoiceCheckLateWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct() {}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
nlog("sending overdue webhooks for invoices");
if (! config('ninja.db.multi_db_enabled')){
$company_ids = Webhook::where('event_id', Webhook::EVENT_LATE_INVOICE)
->where('is_deleted', 0)
->pluck('company_id');
Invoice::query()
->where('is_deleted', false)
->whereNull('deleted_at')
->whereNotNull('due_date')
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
->where('balance', '>', 0)
->whereIn('company_id', $company_ids)
->whereHas('client', function ($query) {
$query->where('is_deleted', 0)
->where('deleted_at', null);
})
->whereHas('company', function ($query){
$query->where('is_disabled', 0);
})
->whereBetween('due_date', [now()->subDay()->startOfDay(), now()->startOfDay()->subSecond()])
->cursor()
->each(function ($invoice){
WebhookHandler::dispatch(Webhook::EVENT_LATE_INVOICE, $invoice, $invoice->company, 'client')->delay(now()->addSeconds(2));
});
}
else {
foreach (MultiDB::$dbs as $db)
{
MultiDB::setDB($db);
$company_ids = Webhook::where('event_id', Webhook::EVENT_LATE_INVOICE)
->where('is_deleted', 0)
->pluck('company_id');
Invoice::query()
->where('is_deleted', false)
->whereNull('deleted_at')
->whereNotNull('due_date')
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
->where('balance', '>', 0)
->whereIn('company_id', $company_ids)
->whereHas('client', function ($query) {
$query->where('is_deleted', 0)
->where('deleted_at', null);
})
->whereHas('company', function ($query){
$query->where('is_disabled', 0);
})
->whereBetween('due_date', [now()->subDay()->startOfDay(), now()->startOfDay()->subSecond()])
->cursor()
->each(function ($invoice){
WebhookHandler::dispatch(Webhook::EVENT_LATE_INVOICE, $invoice, $invoice->company, 'client')->delay(now()->addSeconds(2));
});
}
}
}
}

View File

@ -349,6 +349,26 @@ class Import implements ShouldQueue
} }
$account = $this->company->account; $account = $this->company->account;
/* If the user has upgraded their account, do not wipe their payment plan*/
if($account->isPaid() || (isset($data['plan']) && $data['plan'] == 'white_label'))
{
if(isset($data['plan']))
unset($data['plan']);
if(isset($data['plan_term']))
unset($data['plan_term']);
if(isset($data['plan_paid']))
unset($data['plan_paid']);
if(isset($data['plan_started']))
unset($data['plan_started']);
if(isset($data['plan_expires']))
unset($data['plan_expires']);
}
$account->fill($data); $account->fill($data);
$account->save(); $account->save();

View File

@ -214,21 +214,33 @@ class Activity extends StaticModel
'backup', 'backup',
]; ];
/**
* @return mixed
*/
public function getHashedIdAttribute() public function getHashedIdAttribute()
{ {
return $this->encodePrimaryKey($this->id); return $this->encodePrimaryKey($this->id);
} }
/**
* @return mixed
*/
public function getEntityType() public function getEntityType()
{ {
return self::class; return self::class;
} }
/**
* @return mixed
*/
public function backup() public function backup()
{ {
return $this->hasOne(Backup::class); return $this->hasOne(Backup::class);
} }
/**
* @return mixed
*/
public function history() public function history()
{ {
return $this->hasOne(Backup::class); return $this->hasOne(Backup::class);
@ -266,6 +278,9 @@ class Activity extends StaticModel
return $this->belongsTo(Invoice::class)->withTrashed(); return $this->belongsTo(Invoice::class)->withTrashed();
} }
/**
* @return mixed
*/
public function vendor() public function vendor()
{ {
return $this->belongsTo(Vendor::class)->withTrashed(); return $this->belongsTo(Vendor::class)->withTrashed();
@ -279,6 +294,9 @@ class Activity extends StaticModel
return $this->belongsTo(RecurringInvoice::class)->withTrashed(); return $this->belongsTo(RecurringInvoice::class)->withTrashed();
} }
/**
* @return mixed
*/
public function credit() public function credit()
{ {
return $this->belongsTo(Credit::class)->withTrashed(); return $this->belongsTo(Credit::class)->withTrashed();
@ -308,31 +326,57 @@ class Activity extends StaticModel
return $this->belongsTo(Payment::class)->withTrashed(); return $this->belongsTo(Payment::class)->withTrashed();
} }
/**
* @return mixed
*/
public function expense() public function expense()
{ {
return $this->belongsTo(Expense::class)->withTrashed(); return $this->belongsTo(Expense::class)->withTrashed();
} }
/**
* @return mixed
*/
public function recurring_expense()
{
return $this->belongsTo(RecurringExpense::class)->withTrashed();
}
/**
* @return mixed
*/
public function purchase_order() public function purchase_order()
{ {
return $this->belongsTo(PurchaseOrder::class)->withTrashed(); return $this->belongsTo(PurchaseOrder::class)->withTrashed();
} }
/**
* @return mixed
*/
public function vendor_contact() public function vendor_contact()
{ {
return $this->belongsTo(VendorContact::class)->withTrashed(); return $this->belongsTo(VendorContact::class)->withTrashed();
} }
/**
* @return mixed
*/
public function task() public function task()
{ {
return $this->belongsTo(Task::class)->withTrashed(); return $this->belongsTo(Task::class)->withTrashed();
} }
/**
* @return mixed
*/
public function company() public function company()
{ {
return $this->belongsTo(Company::class); return $this->belongsTo(Company::class);
} }
/**
* @return mixed
*/
public function resolveRouteBinding($value, $field = null) public function resolveRouteBinding($value, $field = null)
{ {
if (is_numeric($value)) { if (is_numeric($value)) {

View File

@ -102,21 +102,23 @@ class Gateway extends StaticModel
break; break;
case 20: case 20:
return [ return [
GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true], GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true, 'webhooks' => ['payment_intent.succeeded']],
GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded', 'charge.failed', 'payment_intent.payment_failed']], GatewayType::BANK_TRANSFER => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::ALIPAY => ['refund' => false, 'token_billing' => false], GatewayType::ALIPAY => ['refund' => false, 'token_billing' => false],
GatewayType::APPLE_PAY => ['refund' => false, 'token_billing' => false], GatewayType::APPLE_PAY => ['refund' => false, 'token_billing' => false],
GatewayType::SOFORT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']], //Stripe GatewayType::SOFORT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::SEPA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::PRZELEWY24 => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::GIROPAY => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::EPS => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::BANCONTACT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::BECS => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::IDEAL => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::ACSS => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
GatewayType::KLARNA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']], GatewayType::KLARNA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::FPX => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']], ]; GatewayType::SEPA => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::PRZELEWY24 => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::GIROPAY => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::EPS => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::BANCONTACT => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::BECS => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::IDEAL => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::ACSS => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded', 'payment_intent.succeeded']],
GatewayType::FPX => ['refund' => true, 'token_billing' => true, 'webhooks' => ['source.chargeable', 'charge.succeeded']],
];
break;
case 39: case 39:
return [GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true, 'webhooks' => [' ']]]; //Checkout return [GatewayType::CREDIT_CARD => ['refund' => true, 'token_billing' => true, 'webhooks' => [' ']]]; //Checkout
break; break;

View File

@ -11,6 +11,7 @@
namespace App\Models; namespace App\Models;
use App\Models\Filterable;
use App\Models\RecurringInvoice; use App\Models\RecurringInvoice;
use App\Services\Subscription\SubscriptionService; use App\Services\Subscription\SubscriptionService;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -19,7 +20,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Subscription extends BaseModel class Subscription extends BaseModel
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes, Filterable;
protected $hidden = [ protected $hidden = [
'id', 'id',

View File

@ -627,7 +627,9 @@ class StripePaymentDriver extends BaseDriver
public function processWebhookRequest(PaymentWebhookRequest $request) public function processWebhookRequest(PaymentWebhookRequest $request)
{ {
// if($request->type === 'payment_intent.requires_action')
// nlog($request->all());
//payment_intent.succeeded - this will confirm or cancel the payment //payment_intent.succeeded - this will confirm or cancel the payment
if ($request->type === 'payment_intent.succeeded') { if ($request->type === 'payment_intent.succeeded') {
PaymentIntentWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(rand(5, 10))); PaymentIntentWebhook::dispatch($request->data, $request->company_key, $this->company_gateway->id)->delay(now()->addSeconds(rand(5, 10)));

View File

@ -0,0 +1,57 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Schedule;
class ScheduleService
{
public function __construct(public Scheduler $scheduler) {}
public function scheduleStatement()
{
//Is it for one client
//Is it for all clients
//Is it for all clients excluding these clients
//Frequency
//show aging
//show payments
//paid/unpaid
//When to send? 1st of month
//End of month
//This date
}
public function scheduleReport()
{
//Report type
//same schema as ScheduleStatement
}
public function scheduleEntitySend()
{
//Entity
//Entity Id
//When
}
public function projectStatus()
{
//Project ID
//Tasks - task statuses
}
}

View File

@ -1,5 +1,4 @@
<?php <?php
/** /**
* Invoice Ninja (https://invoiceninja.com). * Invoice Ninja (https://invoiceninja.com).
* *
@ -40,10 +39,6 @@ trait AppSetup
foreach ($cached_tables as $name => $class) { foreach ($cached_tables as $name => $class) {
if (! Cache::has($name) || $force) { if (! Cache::has($name) || $force) {
// check that the table exists in case the migration is pending
if (! Schema::hasTable((new $class())->getTable())) {
continue;
}
if ($name == 'payment_terms') { if ($name == 'payment_terms') {
$orderBy = 'num_days'; $orderBy = 'num_days';
} elseif ($name == 'fonts') { } elseif ($name == 'fonts') {

View File

@ -14,8 +14,8 @@ return [
'require_https' => env('REQUIRE_HTTPS', true), 'require_https' => env('REQUIRE_HTTPS', true),
'app_url' => rtrim(env('APP_URL', ''), '/'), 'app_url' => rtrim(env('APP_URL', ''), '/'),
'app_domain' => env('APP_DOMAIN', 'invoicing.co'), 'app_domain' => env('APP_DOMAIN', 'invoicing.co'),
'app_version' => '5.5.50', 'app_version' => '5.5.51',
'app_tag' => '5.5.50', 'app_tag' => '5.5.51',
'minimum_client_version' => '5.0.16', 'minimum_client_version' => '5.0.16',
'terms_version' => '1.0.1', 'terms_version' => '1.0.1',
'api_secret' => env('API_SECRET', ''), 'api_secret' => env('API_SECRET', ''),

View File

@ -70,6 +70,7 @@ use App\Http\Controllers\Reports\InvoiceItemReportController;
use App\Http\Controllers\Reports\InvoiceReportController; use App\Http\Controllers\Reports\InvoiceReportController;
use App\Http\Controllers\Reports\PaymentReportController; use App\Http\Controllers\Reports\PaymentReportController;
use App\Http\Controllers\Reports\ProductReportController; use App\Http\Controllers\Reports\ProductReportController;
use App\Http\Controllers\Reports\ProductSalesReportController;
use App\Http\Controllers\Reports\ProfitAndLossController; use App\Http\Controllers\Reports\ProfitAndLossController;
use App\Http\Controllers\Reports\QuoteItemReportController; use App\Http\Controllers\Reports\QuoteItemReportController;
use App\Http\Controllers\Reports\QuoteReportController; use App\Http\Controllers\Reports\QuoteReportController;
@ -270,6 +271,7 @@ Route::group(['middleware' => ['throttle:300,1', 'api_db', 'token_auth', 'locale
Route::post('reports/recurring_invoices', RecurringInvoiceReportController::class); Route::post('reports/recurring_invoices', RecurringInvoiceReportController::class);
Route::post('reports/payments', PaymentReportController::class); Route::post('reports/payments', PaymentReportController::class);
Route::post('reports/products', ProductReportController::class); Route::post('reports/products', ProductReportController::class);
Route::post('reports/product_sales', ProductSalesReportController::class);
Route::post('reports/tasks', TaskReportController::class); Route::post('reports/tasks', TaskReportController::class);
Route::post('reports/profitloss', ProfitAndLossController::class); Route::post('reports/profitloss', ProfitAndLossController::class);

View File

@ -0,0 +1,212 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace Tests\Feature\Export;
use App\DataMapper\ClientSettings;
use App\DataMapper\CompanySettings;
use App\Export\CSV\ProductSalesExport;
use App\Factory\ExpenseCategoryFactory;
use App\Factory\ExpenseFactory;
use App\Factory\InvoiceFactory;
use App\Factory\InvoiceItemFactory;
use App\Models\Account;
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\Company;
use App\Models\Expense;
use App\Models\ExpenseCategory;
use App\Models\Invoice;
use App\Models\User;
use App\Services\Report\ProfitLoss;
use App\Utils\Traits\MakesHash;
use Database\Factories\ClientContactFactory;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Storage;
use League\Csv\Writer;
use Tests\MockAccountData;
use Tests\TestCase;
/**
* @test
* @covers App\Services\Report\ProductSalesExport
*/
class ProductSalesReportTest extends TestCase
{
use MakesHash;
public $faker;
protected function setUp() :void
{
parent::setUp();
$this->faker = \Faker\Factory::create();
$this->withoutMiddleware(
ThrottleRequests::class
);
$this->withoutExceptionHandling();
}
public $company;
public $user;
public $payload;
public $account;
/**
* start_date - Y-m-d
end_date - Y-m-d
date_range -
all
last7
last30
this_month
last_month
this_quarter
last_quarter
this_year
custom
is_income_billed - true = Invoiced || false = Payments
expense_billed - true = Expensed || false = Expenses marked as paid
include_tax - true tax_included || false - tax_excluded
*/
private function buildData()
{
$this->account = Account::factory()->create([
'hosted_client_count' => 1000,
'hosted_company_count' => 1000,
]);
$this->account->num_users = 3;
$this->account->save();
$this->user = User::factory()->create([
'account_id' => $this->account->id,
'confirmation_code' => 'xyz123',
'email' => $this->faker->unique()->safeEmail(),
]);
$settings = CompanySettings::defaults();
$settings->client_online_payment_notification = false;
$settings->client_manual_payment_notification = false;
$this->company = Company::factory()->create([
'account_id' => $this->account->id,
'settings' => $settings,
]);
$this->payload = [
'start_date' => '2000-01-01',
'end_date' => '2030-01-11',
'date_range' => 'custom',
'is_income_billed' => true,
'include_tax' => false,
];
}
public function testProductSalesInstance()
{
$this->buildData();
$pl = new ProductSalesExport($this->company, $this->payload);
$this->assertInstanceOf(ProductSalesExport::class, $pl);
$this->account->delete();
}
public function testSimpleReport()
{
$this->buildData();
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
'is_deleted' => 0,
]);
$this->payload = [
'start_date' => '2000-01-01',
'end_date' => '2030-01-11',
'date_range' => 'custom',
'client_id' => $client->id,
'report_keys' => []
];
$i = Invoice::factory()->create([
'client_id' => $client->id,
'user_id' => $this->user->id,
'company_id' => $this->company->id,
'amount' => 0,
'balance' => 0,
'status_id' => 2,
'total_taxes' => 1,
'date' => now()->format('Y-m-d'),
'terms' => 'nada',
'discount' => 0,
'tax_rate1' => 0,
'tax_rate2' => 0,
'tax_rate3' => 0,
'tax_name1' => '',
'tax_name2' => '',
'tax_name3' => '',
'uses_inclusive_taxes' => false,
'line_items' => $this->buildLineItems(),
]);
$i = $i->calc()->getInvoice();
$pl = new ProductSalesExport($this->company, $this->payload);
$response = $pl->run();
$this->assertIsString($response);
// nlog($response);
$this->account->delete();
}
private function buildLineItems()
{
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 10;
$item->product_key = 'test';
$item->notes = 'test_product';
// $item->task_id = $this->encodePrimaryKey($this->task->id);
// $item->expense_id = $this->encodePrimaryKey($this->expense->id);
$line_items[] = $item;
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 10;
$item->product_key = 'pumpkin';
$item->notes = 'test_pumpkin';
// $item->task_id = $this->encodePrimaryKey($this->task->id);
// $item->expense_id = $this->encodePrimaryKey($this->expense->id);
$line_items[] = $item;
return $line_items;
}
}

View File

@ -37,12 +37,12 @@ class PdfCreatorTest extends TestCase
); );
} }
public function testCreditPdfCreated() // public function testCreditPdfCreated()
{ // {
$credit_path = (new CreateEntityPdf($this->credit->invitations->first()))->handle(); // $credit_path = (new CreateEntityPdf($this->credit->invitations->first()))->handle();
$this->assertTrue(Storage::exists($credit_path)); // $this->assertTrue(Storage::exists($credit_path));
} // }
public function testInvoicePdfCreated() public function testInvoicePdfCreated()
{ {