Merge branch 'v5-develop' into bank_rules

This commit is contained in:
David Bomba 2022-11-20 11:08:09 +11:00
commit f6f659521f
74 changed files with 267807 additions and 266016 deletions

View File

@ -18,18 +18,33 @@ Just make sure to add the `invoice-ninja` tag to your question.
Version 5 of Invoice Ninja is here! We've taken the best parts of version 4 and bolted on all of the most requested features to produce a invoicing application like no other.
The new interface has a lot more functionality so it isn't a carbon copy of v4, but once you get used to the new layout and functionality we are sure you will love it!
All Pro and Enterprise features from the hosted app are included in the open-code. We offer a $30 per year white-label license to remove the Invoice Ninja branding from client facing parts of the app.
## Referral Program
* Earn 50% of Pro & Enterprise Plans up to 4 years - [Learn more](https://www.invoiceninja.com/referral-program/)
* [Videos](https://www.youtube.com/@appinvoiceninja)
* [API Documentation](https://app.swaggerhub.com/apis/invoiceninja/invoiceninja)
* [APP Documentation](https://invoiceninja.github.io/)
* [Support Forum](https://forum.invoiceninja.com)
* [StackOverflow](https://stackoverflow.com/tags/invoice-ninja/)
## Mobile Apps
* [iPhone](https://apps.apple.com/app/id1503970375?platform=iphone)
* [Android](https://play.google.com/store/apps/details?id=com.invoiceninja.app)
## Desktop Apps
* [macOS](https://apps.apple.com/app/id1503970375?platform=mac)
* [Windows](https://microsoft.com/en-us/p/invoice-ninja/9n3f2bbcfdr6)
* [Linux](https://snapcraft.io/invoiceninja)
## Installation Options
* [Docker File](https://hub.docker.com/r/invoiceninja/invoiceninja/)
* [Cloudron](https://cloudron.io/store/com.invoiceninja.cloudronapp.html)
* [Softaculous](https://www.softaculous.com/apps/ecommerce/Invoice_Ninja)
## Recommended Providers
* [Stripe](https://stripe.com/)
* [Postmark](https://postmarkapp.com/)
## Development
* [API Documentation](https://app.swaggerhub.com/apis/invoiceninja/invoiceninja)
* [APP Documentation](https://invoiceninja.github.io/)
## Quick Start
@ -67,43 +82,11 @@ user: user@example.com
pass: password
```
## Contribution guide.
Code Style to follow [PSR-2](https://www.php-fig.org/psr/psr-2/) standards.
All methods names to be in CamelCase
All variables names to be in snake_case
Where practical code should be strongly typed, ie your methods must return a type ie
`public function doThis() : void`
PHP >= 7.3 allows the return type Nullable so there should be no circumstance a type cannot be return by using the following:
`public function doThat() ?:string`
To improve chances of PRs being merged please include tests to ensure your code works well and integrates with the rest of the project.
## Documentation
API documentation is hosted using Swagger and can be found [HERE](https://app.swaggerhub.com/apis/invoiceninja/invoiceninja)
Installation, Configuration and Troubleshooting documentation can be found [HERE] (https://invoiceninja.github.io)
## Credits
* [Hillel Coren](https://hillelcoren.com/)
* [David Bomba](https://github.com/turbo124)
* [All contributors](https://github.com/invoiceninja/invoiceninja/graphs/contributors)
**Special thanks to:**
* [Holger Lösken](https://github.com/codedge) - [codedge](http://codedge.de)
* [Samuel Laulhau](https://github.com/lalop) - [Lalop](http://lalop.co/)
* [Alexander Vanderveen](https://blog.technicallycomputers.ca/) - [Technically Computers](https://www.technicallycomputers.ca/)
* [Efthymios Sarmpanis](https://github.com/esarbanis)
* [Gianfranco Gasbarri](https://github.com/gincos)
* [Clemens Mol](https://github.com/clemensmol)
* [Benjamin Beganović](https://github.com/beganovich)
* [All contributors](https://github.com/invoiceninja/invoiceninja/graphs/contributors)
## Security

View File

@ -1 +1 @@
5.5.38
5.5.41

View File

@ -175,7 +175,7 @@ class SendRemindersCron extends Command
/**Refresh Invoice values*/
$invoice->calc()->getInvoice()->save();
$invoice->fresh();
$invoice->service()->deletePdf();
$invoice->service()->deletePdf()->save();
/* Refresh the client here to ensure the balance is fresh */
$client = $invoice->client;

View File

@ -22,6 +22,7 @@ use App\Jobs\Ninja\CompanySizeCheck;
use App\Jobs\Ninja\QueueSize;
use App\Jobs\Ninja\SystemMaintenance;
use App\Jobs\Ninja\TaskScheduler;
use App\Jobs\Quote\QuoteCheckExpired;
use App\Jobs\Util\DiskCleanup;
use App\Jobs\Util\ReminderJob;
use App\Jobs\Util\SchedulerCheck;
@ -70,6 +71,9 @@ class Kernel extends ConsoleKernel
/* Sends recurring invoices*/
$schedule->job(new RecurringExpensesCron)->dailyAt('00:10')->withoutOverlapping();
/* Fires notifications for expired Quotes */
$schedule->job(new QuoteCheckExpired)->dailyAt('05:00')->withoutOverlapping();
/* Performs auto billing */
$schedule->job(new AutoBillCron)->dailyAt('06:00')->withoutOverlapping();

View File

@ -55,6 +55,55 @@ class BankTransactionFilters extends QueryFilters
}
/**
* Filter based on client status.
*
* Statuses we need to handle
* - all
* - unmatched
* - matched
* - converted
* - deposits
* - withdrawals
*
* @return Builder
*/
public function client_status(string $value = '') :Builder
{
if (strlen($value) == 0) {
return $this->builder;
}
$status_parameters = explode(',', $value);
if (in_array('all', $status_parameters)) {
return $this->builder;
}
if (in_array('unmatched', $status_parameters)) {
$this->builder->where('status_id', BankTransaction::STATUS_UNMATCHED);
}
if (in_array('matched', $status_parameters)) {
$this->builder->where('status_id', BankTransaction::STATUS_MATCHED);
}
if (in_array('converted', $status_parameters)) {
$this->builder->where('status_id', BankTransaction::STATUS_CONVERTED);
}
if (in_array('deposits', $status_parameters)) {
$this->builder->where('base_type', 'CREDIT');
}
if (in_array('withdrawals', $status_parameters)) {
$this->builder->where('base_type', 'DEBIT');
}
return $this->builder;
}
/**
* Filters the list based on the status
* archived, active, deleted.

View File

@ -44,6 +44,55 @@ class ExpenseFilters extends QueryFilters
});
}
/**
* Filter based on client status.
*
* Statuses we need to handle
* - all
* - logged
* - pending
* - invoiced
* - paid
* - unpaid
*
* @return Builder
*/
public function client_status(string $value = '') :Builder
{
if (strlen($value) == 0) {
return $this->builder;
}
$status_parameters = explode(',', $value);
if (in_array('all', $status_parameters)) {
return $this->builder;
}
if (in_array('logged', $status_parameters)) {
$this->builder->where('amount', '>', 0);
}
if (in_array('pending', $status_parameters)) {
$this->builder->whereNull('invoice_id')->whereNotNull('payment_date');
}
if (in_array('invoiced', $status_parameters)) {
$this->builder->whereNotNull('invoice_id');
}
if (in_array('paid', $status_parameters)) {
$this->builder->whereNotNull('payment_date');
}
if (in_array('unpaid', $status_parameters)) {
$this->builder->whereNull('payment_date');
}
return $this->builder;
}
/**
* Filters the list based on the status
* archived, active, deleted.

View File

@ -17,19 +17,20 @@ use Illuminate\Database\Eloquent\Builder;
class PurchaseOrderFilters extends QueryFilters
{
/**
* Filter based on client status.
*
* Statuses we need to handle
* - all
* - paid
* - unpaid
* - overdue
* - reversed
* - draft
* - sent
* - accepted
* - cancelled
*
* @return Builder
*/
public function credit_status(string $value = '') :Builder
public function client_status(string $value = '') :Builder
{
if (strlen($value) == 0) {
return $this->builder;
@ -45,16 +46,17 @@ class PurchaseOrderFilters extends QueryFilters
$this->builder->where('status_id', PurchaseOrder::STATUS_DRAFT);
}
if (in_array('partial', $status_parameters)) {
$this->builder->where('status_id', PurchaseOrder::STATUS_PARTIAL);
if (in_array('sent', $status_parameters)) {
$this->builder->where('status_id', PurchaseOrder::STATUS_SENT);
}
if (in_array('applied', $status_parameters)) {
$this->builder->where('status_id', PurchaseOrder::STATUS_APPLIED);
if (in_array('accepted', $status_parameters)) {
$this->builder->where('status_id', PurchaseOrder::STATUS_ACCEPTED);
}
//->where('due_date', '>', Carbon::now())
//->orWhere('partial_due_date', '>', Carbon::now());
if (in_array('cancelled', $status_parameters)) {
$this->builder->where('status_id', PurchaseOrder::STATUS_CANCELLED);
}
return $this->builder;
}

View File

@ -11,6 +11,7 @@
namespace App\Filters;
use App\Models\Quote;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
@ -41,6 +42,51 @@ class QuoteFilters extends QueryFilters
});
}
/**
* Filter based on client status.
*
* Statuses we need to handle
* - all
* - active
* - paused
* - completed
*
* @param string client_status The invoice status as seen by the client
* @return Builder
*/
public function client_status(string $value = '') :Builder
{
if (strlen($value) == 0) {
return $this->builder;
}
$status_parameters = explode(',', $value);
if (in_array('all', $status_parameters)) {
return $this->builder;
}
if (in_array('draft', $status_parameters)) {
$this->builder->where('status_id', Quote::STATUS_DRAFT);
}
if (in_array('sent', $status_parameters)) {
$this->builder->where('status_id', Quote::STATUS_SENT);
}
if (in_array('approved', $status_parameters)) {
$this->builder->where('status_id', Quote::STATUS_APPROVED);
}
if (in_array('expired', $status_parameters)) {
$this->builder->where('status_id', Quote::STATUS_SENT)
->where('due_date', '<=', now()->toDateString());
}
return $this->builder;
}
/**
* Filters the list based on the status
* archived, active, deleted.

View File

@ -11,6 +11,7 @@
namespace App\Filters;
use App\Models\RecurringInvoice;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
@ -40,6 +41,46 @@ class RecurringInvoiceFilters extends QueryFilters
});
}
/**
* Filter based on client status.
*
* Statuses we need to handle
* - all
* - active
* - paused
* - completed
*
* @param string client_status The invoice status as seen by the client
* @return Builder
*/
public function client_status(string $value = '') :Builder
{
if (strlen($value) == 0) {
return $this->builder;
}
$status_parameters = explode(',', $value);
if (in_array('all', $status_parameters)) {
return $this->builder;
}
if (in_array('active', $status_parameters)) {
$this->builder->where('status_id', RecurringInvoice::STATUS_ACTIVE);
}
if (in_array('paused', $status_parameters)) {
$this->builder->where('status_id', RecurringInvoice::STATUS_PAUSED);
}
if (in_array('completed', $status_parameters)) {
$this->builder->where('status_id', RecurringInvoice::STATUS_COMPLETED);
}
return $this->builder;
}
/**
* Filters the list based on the status
* archived, active, deleted.

View File

@ -41,6 +41,37 @@ class TaskFilters extends QueryFilters
});
}
/**
* Filter based on client status.
*
* Statuses we need to handle
* - all
* - invoiced
*
* @param string client_status The invoice status as seen by the client
* @return Builder
*/
public function client_status(string $value = '') :Builder
{
if (strlen($value) == 0) {
return $this->builder;
}
$status_parameters = explode(',', $value);
if (in_array('all', $status_parameters)) {
return $this->builder;
}
if (in_array('invoiced', $status_parameters)) {
$this->builder->whereNotNull('invoice_id');
}
return $this->builder;
}
/**
* Filters the list based on the status
* archived, active, deleted.

View File

@ -19,7 +19,7 @@ use Sprain\SwissQrBill as QrBill;
/**
* SwissQrGenerator.
*/
class SwissQrGenerator
class SwissQrGenerator
{
protected Company $company;
@ -33,7 +33,7 @@ class SwissQrGenerator
$this->company = $company;
$this->invoice = $invoice;
$this->client = $invoice->client;
}
@ -104,15 +104,43 @@ class SwissQrGenerator
// Add payment reference
// This is what you will need to identify incoming payments.
if(stripos($this->invoice->number, "Live") === 0)
{
// we're currently in preview status. Let's give a dummy reference for now
$invoice_number = "123456789";
}
else
{
$tempInvoiceNumber = $this->invoice->number;
$tempInvoiceNumber = preg_replace('/[^A-Za-z0-9]/', '', $tempInvoiceNumber);
$tempInvoiceNumber = substr($tempInvoiceNumber, 1);
$calcInvoiceNumber = "";
$array = str_split($tempInvoiceNumber);
foreach($array as $char)
{
if (is_numeric($char))
{
//
}
else
{
if ($char)
{
$char = strtolower($char);
$char = ord($char) - 96;
}
else
{
return 0;
}
}
$calcInvoiceNumber .= $char;
}
$invoice_number = $calcInvoiceNumber;
if(stripos($this->invoice->number, "Live") === 0)
{
// we're currently in preview status. Let's give a dummy reference for now
$invoice_number = "123456789";
}
else
{
$invoice_number = iconv("UTF-8", "ASCII", $this->invoice->number);
}
if(strlen($this->company->present()->besr_id()) > 1)

View File

@ -12,6 +12,7 @@
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\BankTransaction;
use App\Models\Company;
use App\Models\User;
use App\Transformers\ArraySerializer;
@ -819,12 +820,15 @@ class BaseController extends Controller
// 10-01-2022 need to ensure we snake case properly here to ensure permissions work as expected
// 28-03-2022 this is definitely correct here, do not append _ to the view, it resolved correctly when snake cased
if (auth()->user() && ! auth()->user()->hasPermission('view'.lcfirst(class_basename(Str::snake($this->entity_type))))) {
//06-10-2022 - some entities do not have assigned_user_id - this becomes an issue when we have a large company and low permission users
if(lcfirst(class_basename(Str::snake($this->entity_type))) == 'user')
$query->where('id', auth()->user()->id);
elseif(in_array(lcfirst(class_basename(Str::snake($this->entity_type))),['design','group_setting','payment_term','bank_transaction'])){
//need to pass these back regardless
elseif($this->entity_type == BankTransaction::class){ //table without assigned_user_id
$query->where('user_id', '=', auth()->user()->id);
}
elseif(in_array(lcfirst(class_basename(Str::snake($this->entity_type))),['design','group_setting','payment_term'])){
//need to pass these back regardless
nlog($this->entity_type);
}
else
$query->where('user_id', '=', auth()->user()->id)->orWhere('assigned_user_id', auth()->user()->id);
@ -996,42 +1000,6 @@ class BaseController extends Controller
return redirect('/setup');
}
public function reactCatch()
{
if ((bool) $this->checkAppSetup() !== false && $account = Account::first()) {
if (config('ninja.require_https') && ! request()->isSecure()) {
return redirect()->secure(request()->getRequestUri());
}
$data = [];
//pass report errors bool to front end
$data['report_errors'] = Ninja::isSelfHost() ? $account->report_errors : true;
//pass referral code to front end
$data['rc'] = request()->has('rc') ? request()->input('rc') : '';
$data['build'] = request()->has('build') ? request()->input('build') : '';
$data['login'] = request()->has('login') ? request()->input('login') : 'false';
$data['signup'] = request()->has('signup') ? request()->input('signup') : 'false';
$data['user_agent'] = request()->server('HTTP_USER_AGENT');
$data['path'] = $this->setBuild();
$this->buildCache();
if (Ninja::isSelfHost() && $account->set_react_as_default_ap) {
return view('react.index', $data);
} else {
abort('page not found', 404);
}
}
return redirect('/setup');
}
private function setBuild()
{
$build = '';

View File

@ -165,8 +165,11 @@ class TwilioController extends BaseController
if($verification_check->status == 'approved'){
if($request->query('validate_only') == 'true')
if($request->query('validate_only') == 'true'){
$user->verified_phone_number = true;
$user->save();
return response()->json(['message' => 'SMS verified'], 200);
}
$user->google_2fa_secret = '';
$user->sms_verification_code = '';

View File

@ -26,7 +26,7 @@ class UpdateAccountRequest extends Request
*/
public function authorize()
{
return (auth()->user()->isAdmin() || auth()->user()->isOwner()) && ($this->account->id == auth()->user()->account_id);
return (auth()->user()->isAdmin() || auth()->user()->isOwner()) && ($this->account->id == auth()->user()->token()->account_id);
}
/**

View File

@ -31,12 +31,12 @@ class MatchBankTransactionRequest extends Request
$rules = [
'transactions' => 'bail|array',
'transactions.*.id' => 'bail|required',
'transactions.*.invoice_ids' => 'nullable|string|sometimes',
];
$rules['transactions.*.ninja_category_id'] = 'bail|nullable|sometimes|exists:expense_categories,id,company_id,'.auth()->user()->company()->id.',is_deleted,0';
$rules['transactions.*.vendor_id'] = 'bail|sometimes|exists:vendors,id,company_id,'.auth()->user()->company()->id.',is_deleted,0';
$rules['transactions.*.id'] = 'bail|required|exists:bank_transactions,id,company_id,'.auth()->user()->company()->id.',is_deleted,0';
return $rules;

View File

@ -34,6 +34,9 @@ class StoreBankTransactionRequest extends Request
$rules = [];
if(isset($this->bank_integration_id))
$rules['bank_integration_id'] = 'bail|required|exists:bank_integrations,id,company_id,'.auth()->user()->company()->id.',is_deleted,0';
return $rules;
}

View File

@ -45,6 +45,9 @@ class UpdateBankTransactionRequest extends Request
if(isset($this->expense_id))
$rules['expense_id'] = 'bail|required|exists:expenses,id,company_id,'.auth()->user()->company()->id.',is_deleted,0';
if(isset($this->bank_integration_id))
$rules['bank_integration_id'] = 'bail|required|exists:bank_integrations,id,company_id,'.auth()->user()->company()->id.',is_deleted,0';
return $rules;
}

View File

@ -22,6 +22,14 @@ class UpdateCompanyRequest extends Request
{
use MakesHash;
private array $protected_input = [
'client_portal_privacy_policy',
'client_portal_terms',
'portal_custom_footer',
'portal_custom_css',
'portal_custom_head'
];
/**
* Determine if the user is authorized to make this request.
*
@ -32,6 +40,8 @@ class UpdateCompanyRequest extends Request
return auth()->user()->can('edit', $this->company);
}
public function rules()
{
$input = $this->all();
@ -90,6 +100,14 @@ class UpdateCompanyRequest extends Request
{
$account = $this->company->account;
if(Ninja::isHosted())
{
foreach($this->protected_input as $protected_var)
{
$settings[$protected_var] = str_replace("script", "", $settings[$protected_var]);
}
}
if (! $account->isFreeHostedClient()) {
return $settings;
}

View File

@ -27,6 +27,8 @@ class BlackListRule implements Rule
'superhostforumla.com',
'wnpop.com',
'dataservices.space',
'karenkey.com',
'sharklasers.com',
];
/**

View File

@ -87,7 +87,7 @@ class Csv extends BaseImport implements ImportInterface
foreach($data as $key => $value)
{
$data[$key]['bank.bank_integration_id'] = $this->decodePrimaryKey($this->request['bank_integration_id']);
$data[$key]['transaction.bank_integration_id'] = $this->decodePrimaryKey($this->request['bank_integration_id']);
}
}

View File

@ -31,17 +31,17 @@ class BankTransformer extends BaseTransformer
$now = now();
$transformed = [
'bank_integration_id' => $transaction['bank.bank_integration_id'],
'transaction_id' => $this->getNumber($transaction,'bank.transaction_id'),
'amount' => abs($this->getFloat($transaction, 'bank.amount')),
'currency_id' => $this->getCurrencyByCode($transaction, 'bank.currency'),
'account_type' => strlen($this->getString($transaction, 'bank.account_type')) > 1 ? $this->getString($transaction, 'bank.account_type') : 'bank',
'category_id' => $this->getNumber($transaction, 'bank.category_id') > 0 ? $this->getNumber($transaction, 'bank.category_id') : null,
'category_type' => $this->getString($transaction, 'bank.category_type'),
'date' => array_key_exists('bank.date', $transaction) ? $this->parseDate($transaction['bank.date'])
'bank_integration_id' => $transaction['transaction.bank_integration_id'],
'transaction_id' => $this->getNumber($transaction,'transaction.transaction_id'),
'amount' => abs($this->getFloat($transaction, 'transaction.amount')),
'currency_id' => $this->getCurrencyByCode($transaction, 'transaction.currency'),
'account_type' => strlen($this->getString($transaction, 'transaction.account_type')) > 1 ? $this->getString($transaction, 'transaction.account_type') : 'bank',
'category_id' => $this->getNumber($transaction, 'transaction.category_id') > 0 ? $this->getNumber($transaction, 'transaction.category_id') : null,
'category_type' => $this->getString($transaction, 'transaction.category_type'),
'date' => array_key_exists('transaction.date', $transaction) ? $this->parseDate($transaction['transaction.date'])
: now()->format('Y-m-d'),
'bank_account_id' => array_key_exists('bank.bank_account_id', $transaction) ? $transaction['bank.bank_account_id'] : 0,
'description' => array_key_exists('bank.description', $transaction) ? $transaction['bank.description'] : '',
'bank_account_id' => array_key_exists('transaction.bank_account_id', $transaction) ? $transaction['transaction.bank_account_id'] : 0,
'description' => array_key_exists('transaction.description', $transaction) ? $transaction['transaction.description'] : '',
'base_type' => $this->calculateType($transaction),
'created_at' => $now,
'updated_at' => $now,
@ -56,22 +56,22 @@ class BankTransformer extends BaseTransformer
private function calculateType($transaction)
{
if(array_key_exists('bank.base_type', $transaction) && ($transaction['bank.base_type'] == 'CREDIT') || strtolower($transaction['bank.base_type']) == 'deposit')
if(array_key_exists('transaction.base_type', $transaction) && (($transaction['transaction.base_type'] == 'CREDIT') || strtolower($transaction['transaction.base_type']) == 'deposit'))
return 'CREDIT';
if(array_key_exists('bank.base_type', $transaction) && ($transaction['bank.base_type'] == 'DEBIT') || strtolower($transaction['bank.bank_type']) == 'withdrawal')
if(array_key_exists('transaction.base_type', $transaction) && (($transaction['transaction.base_type'] == 'DEBIT') || strtolower($transaction['transaction.bank_type']) == 'withdrawal'))
return 'DEBIT';
if(array_key_exists('bank.category_id', $transaction))
if(array_key_exists('transaction.category_id', $transaction))
return 'DEBIT';
if(array_key_exists('bank.category_type', $transaction) && $transaction['bank.category_type'] == 'Income')
if(array_key_exists('transaction.category_type', $transaction) && $transaction['transaction.category_type'] == 'Income')
return 'CREDIT';
if(array_key_exists('bank.category_type', $transaction))
if(array_key_exists('transaction.category_type', $transaction))
return 'DEBIT';
if(array_key_exists('bank.amount', $transaction) && is_numeric($transaction['bank.amount']) && $transaction['bank.amount'] > 0)
if(array_key_exists('transaction.amount', $transaction) && is_numeric($transaction['transaction.amount']) && $transaction['transaction.amount'] > 0)
return 'CREDIT';
return 'DEBIT';

View File

@ -160,6 +160,9 @@ class MatchBankTransactions implements ShouldQueue
{
$this->bt = BankTransaction::find($input['id']);
if(!$this->bt || $this->bt->status_id == BankTransaction::STATUS_CONVERTED)
return $this;
$_invoices = Invoice::withTrashed()->find($this->getInvoices($input['invoice_ids']));
$amount = $this->bt->amount;
@ -180,6 +183,10 @@ class MatchBankTransactions implements ShouldQueue
//if there is a category id, pull it from Yodlee and insert - or just reuse!!
$this->bt = BankTransaction::find($input['id']);
if(!$this->bt || $this->bt->status_id == BankTransaction::STATUS_CONVERTED)
return $this;
$expense = ExpenseFactory::create($this->bt->company_id, $this->bt->user_id);
$expense->category_id = $this->resolveCategory($input);
$expense->amount = $this->bt->amount;

View File

@ -251,6 +251,13 @@ class NinjaMailerJob implements ShouldQueue
],
]);
if(env($this->company->id . '_MAIL_FROM_ADDRESS'))
{
$this->nmo
->mailable
->from(env($this->company->id . '_MAIL_FROM_ADDRESS', env('MAIL_FROM_ADDRESS')), env($this->company->id . '_MAIL_FROM_NAME', env('MAIL_FROM_NAME')));
}
}
}

View File

@ -0,0 +1,112 @@
<?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\Quote;
use App\Jobs\Mail\NinjaMailer;
use App\Jobs\Mail\NinjaMailerJob;
use App\Jobs\Mail\NinjaMailerObject;
use App\Libraries\MultiDB;
use App\Mail\Admin\QuoteExpiredObject;
use App\Models\Quote;
use App\Repositories\BaseRepository;
use App\Utils\Traits\Notifications\UserNotifies;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class QuoteCheckExpired implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, UserNotifies;
/**
* Create a new job instance.
*/
public function __construct() {}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
if (! config('ninja.db.multi_db_enabled'))
return $this->checkForExpiredQuotes();
foreach (MultiDB::$dbs as $db) {
MultiDB::setDB($db);
$this->checkForExpiredQuotes();
}
}
private function checkForExpiredQuotes()
{
Quote::query()
->where('status_id', Quote::STATUS_SENT)
->where('is_deleted', false)
->whereNull('deleted_at')
->whereNotNull('due_date')
->whereHas('client', function ($query) {
$query->where('is_deleted', 0)
->where('deleted_at', null);
})
->whereHas('company', function ($query) {
$query->where('is_disabled', 0);
})
// ->where('due_date', '<='. now()->toDateTimeString())
->whereBetween('due_date', [now()->subDay()->startOfDay(), now()->startOfDay()->subSecond()])
->cursor()
->each(function ($quote){
$this->queueExpiredQuoteNotification($quote);
});
}
private function queueExpiredQuoteNotification(Quote $quote)
{
$nmo = new NinjaMailerObject;
$nmo->mailable = new NinjaMailer((new QuoteExpiredObject($quote, $quote->company))->build());
$nmo->company = $quote->company;
$nmo->settings = $quote->company->settings;
/* We loop through each user and determine whether they need to be notified */
foreach ($quote->company->company_users as $company_user) {
/* The User */
$user = $company_user->user;
if (! $user) {
continue;
}
/* Returns an array of notification methods */
$methods = $this->findUserNotificationTypes($quote->invitations()->first(), $company_user, 'quote', ['all_notifications', 'quote_expired', 'quote_expired_all']);
/* If one of the methods is email then we fire the EntitySentMailer */
if (($key = array_search('mail', $methods)) !== false) {
unset($methods[$key]);
$nmo->to_user = $user;
NinjaMailerJob::dispatch($nmo);
}
}
}
}

View File

@ -21,8 +21,6 @@ class InvoiceEmailFailedActivity implements ShouldQueue
{
protected $activity_repo;
public $delay = 5;
/**
* Create the event listener.
*

View File

@ -20,8 +20,6 @@ use stdClass;
class InvoicePaidActivity implements ShouldQueue
{
protected $activity_repo;
public $delay = 10;
/**
* Create the event listener.

View File

@ -21,8 +21,6 @@ class UpdateInvoiceActivity implements ShouldQueue
{
protected $activity_repo;
public $delay = 5;
/**
* Create the event listener.
*

View File

@ -0,0 +1,103 @@
<?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\Mail\Admin;
use App\Libraries\MultiDB;
use App\Models\Company;
use App\Models\Quote;
use App\Utils\Ninja;
use App\Utils\Number;
use Illuminate\Support\Facades\App;
use stdClass;
class QuoteExpiredObject
{
public $quote;
public $company;
public $settings;
public function __construct(Quote $quote, Company $company)
{
$this->quote = $quote;
$this->company = $company;
}
public function build()
{
MultiDB::setDb($this->company->db);
if (! $this->quote) {
return;
}
App::forgetInstance('translator');
/* Init a new copy of the translator*/
$t = app('translator');
/* Set the locale*/
App::setLocale($this->company->getLocale());
/* Set customized translations _NOW_ */
$t->replace(Ninja::transformTranslations($this->company->settings));
$mail_obj = new stdClass;
$mail_obj->amount = $this->getAmount();
$mail_obj->subject = $this->getSubject();
$mail_obj->data = $this->getData();
$mail_obj->markdown = 'email.admin.generic';
$mail_obj->tag = $this->company->company_key;
return $mail_obj;
}
private function getAmount()
{
return Number::formatMoney($this->quote->amount, $this->quote->client);
}
private function getSubject()
{
return
ctrans(
'texts.notification_quote_expired_subject',
[
'client' => $this->quote->client->present()->name(),
'invoice' => $this->quote->number,
]
);
}
private function getData()
{
$settings = $this->quote->client->getMergedSettings();
$data = [
'title' => $this->getSubject(),
'message' => ctrans(
'texts.notification_quote_expired',
[
'amount' => $this->getAmount(),
'client' => $this->quote->client->present()->name(),
'invoice' => $this->quote->number,
]
),
'url' => $this->quote->invitations->first()->getAdminLink(),
'button' => ctrans('texts.view_quote'),
'signature' => $settings->email_signature,
'logo' => $this->company->present()->logo(),
'settings' => $settings,
'whitelabel' => $this->company->account->isPaid() ? true : false,
];
return $data;
}
}

View File

@ -243,6 +243,14 @@ class PaymentEmailEngine extends BaseEmailEngine
$data['$invoices.due_date'] = ['value' => $this->formatInvoiceField('due_date'), 'label' => ctrans('texts.invoices')];
$data['$invoices.po_number'] = ['value' => $this->formatInvoiceField('po_number'), 'label' => ctrans('texts.invoices')];
if($this->payment->status_id == 4) {
$data['$status_logo'] = ['value' => '<div class="stamp is-paid"> ' . ctrans('texts.paid') .'</div>', 'label' => ''];
}
else
$data['$status_logo'] = ['value' => '', 'label' => ''];
$arrKeysLength = array_map('strlen', array_keys($data));
array_multisort($arrKeysLength, SORT_DESC, $data);

View File

@ -42,8 +42,10 @@ class CompanyPresenter extends EntityPresenter
return $settings->company_logo;
else if(strlen($settings->company_logo) >= 1)
return url('') . $settings->company_logo;
else
return asset('images/new_logo.png');
else{
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
//return asset('images/new_logo.png');
}
}
@ -88,8 +90,10 @@ class CompanyPresenter extends EntityPresenter
return "data:image/png;base64, ". base64_encode(@file_get_contents($settings->company_logo, false, stream_context_create($context_options)));
else if(strlen($settings->company_logo) >= 1)
return "data:image/png;base64, ". base64_encode(@file_get_contents(url('') . $settings->company_logo, false, stream_context_create($context_options)));
else
return "data:image/png;base64, ". base64_encode(@file_get_contents(asset('images/new_logo.png'), false, stream_context_create($context_options)));
else{
return "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
//return "data:image/png;base64, ". base64_encode(@file_get_contents(asset('images/new_logo.png'), false, stream_context_create($context_options)));
}
}

View File

@ -124,6 +124,7 @@ class RecurringInvoice extends BaseModel
'exchange_rate',
'vendor_id',
'next_send_date_client',
'uses_inclusive_taxes',
];
protected $casts = [

View File

@ -136,3 +136,36 @@ class AuthorizeCreateCustomer
// }
// }
}
// $request = new net\authorize\api\contract\v1\GetCustomerProfileIdsRequest();
// $request->setMerchantAuthentication($auth->merchant_authentication);
// $controller = new net\authorize\api\controller\GetCustomerProfileIdsController($request);
// $response = $controller->executeWithApiResponse($auth->mode());
// // $customer_profile_id = end($response->getIds());
// foreach($response->getIds() as $customer_profile_id)
// {
// $request = new net\authorize\api\contract\v1\GetCustomerProfileRequest();
// $request->setMerchantAuthentication($auth->merchant_authentication);
// $request->setCustomerProfileId($customer_profile_id);
// $controller = new net\authorize\api\controller\GetCustomerProfileController($request);
// $response = $controller->executeWithApiResponse($auth->mode());
// $profileSelected = $response->getProfile();
// if($profileSelected->getEmail() == 'katnandan@gmail.com')
// {
// $profileSelected;
// break;
// }
// }

View File

@ -92,12 +92,14 @@ class CreditCard
$payment_hash = PaymentHash::where('hash', $request->input('payment_hash'))->firstOrFail();
$amount_with_fee = $payment_hash->data->total->amount_with_fee;
$invoice_totals = $payment_hash->data->total->invoice_totals;
$fee_total = 0;
$fee_total = null;
$fees_and_limits = $this->forte->company_gateway->getFeesAndLimits(GatewayType::CREDIT_CARD);
if(property_exists($fees_and_limits, 'fee_percent') && $fees_and_limits->fee_percent > 0)
{
$fee_total = 0;
for ($i = ($invoice_totals * 100) ; $i < ($amount_with_fee * 100); $i++) {
$calculated_fee = ( 3 * $i) / 100;
$calculated_amount_with_fee = round(($i + $calculated_fee) / 100,2);

View File

@ -97,6 +97,8 @@ class InstantBankPay implements MethodInterface
$this->go_cardless->setPaymentHash(
$request->getPaymentHash()
);
$this->go_cardless->init();
try {
$billing_request = $this->go_cardless->gateway->billingRequests()->get(

View File

@ -291,13 +291,13 @@ class GoCardlessPaymentDriver extends BaseDriver
return response()->json([], 200);
}
$this->go_cardless->setPaymentHash($hash);
$this->setPaymentHash($hash);
$billing_request = $this->go_cardless->gateway->billingRequests()->get(
$billing_request = $this->gateway->billingRequests()->get(
$event['links']['billing_request']
);
$payment = $this->go_cardless->gateway->payments()->get(
$payment = $this->gateway->payments()->get(
$billing_request->payment_request->links->payment
);
@ -305,12 +305,12 @@ class GoCardlessPaymentDriver extends BaseDriver
$invoices = Invoice::whereIn('id', $this->transformKeys(array_column($hash->invoices(), 'invoice_id')))->withTrashed()->get();
$this->go_cardless->client = $invoices->first()->client;
$this->client = $invoices->first()->client;
$invoices->each(function ($invoice){
//if payments exist already, they just need to be confirmed.
if($invoice->payments()->exists){
if($invoice->payments()->exists()){
$invoice->payments()->where('status_id', 1)->cursor()->each(function ($payment){
$payment->status_id = 4;
@ -347,12 +347,12 @@ class GoCardlessPaymentDriver extends BaseDriver
$data = [
'payment_method' => $payment->links->mandate,
'payment_type' => PaymentType::INSTANT_BANK_PAY,
'amount' => $this->go_cardless->payment_hash->data->amount_with_fee,
'amount' => $this->payment_hash->data->amount_with_fee,
'transaction_reference' => $payment->id,
'gateway_type_id' => GatewayType::INSTANT_BANK_PAY,
];
$payment = $this->go_cardless->createPayment($data, Payment::STATUS_COMPLETED);
$payment = $this->createPayment($data, Payment::STATUS_COMPLETED);
$payment->status_id = Payment::STATUS_COMPLETED;
$payment->save();
@ -361,8 +361,8 @@ class GoCardlessPaymentDriver extends BaseDriver
SystemLog::CATEGORY_GATEWAY_RESPONSE,
SystemLog::EVENT_GATEWAY_SUCCESS,
SystemLog::TYPE_GOCARDLESS,
$this->go_cardless->client,
$this->go_cardless->client->company,
$this->client,
$this->client->company,
);
}

View File

@ -24,7 +24,7 @@ class BankTransactionRepository extends BaseRepository
public function save($data, BankTransaction $bank_transaction)
{
if(!isset($bank_transaction->bank_integration_id) && array_key_exists('bank_integration_id', $data))
if(array_key_exists('bank_integration_id', $data))
$bank_transaction->bank_integration_id = $data['bank_integration_id'];
$bank_transaction->fill($data);

View File

@ -187,7 +187,7 @@ class BaseRepository
if(!$model->id){
$this->new_model = true;
if(is_array($model->line_items))
if(is_array($model->line_items) && !($model instanceof RecurringInvoice))
{
$model->line_items = (collect($model->line_items))->map(function ($item) use($model,$client) {

View File

@ -45,6 +45,22 @@ class TriggeredActions extends AbstractService
$this->credit = $this->credit->service()->markSent()->save();
}
if($this->request->has('save_default_footer') && $this->request->input('save_default_footer') == 'true') {
$company = $this->credit->company;
$settings = $company->settings;
$settings->credit_footer = $this->credit->footer;
$company->settings = $settings;
$company->save();
}
if($this->request->has('save_default_terms') && $this->request->input('save_default_terms') == 'true') {
$company = $this->credit->company;
$settings = $company->settings;
$settings->credit_terms = $this->credit->terms;
$company->settings = $settings;
$company->save();
}
return $this->credit;
}

View File

@ -57,6 +57,8 @@ class HandleCancellation extends AbstractService
event(new InvoiceWasCancelled($this->invoice, $this->invoice->company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
event('eloquent.updated: App\Models\Invoice', $this->invoice);
$transaction = [
'invoice' => $this->invoice->transaction_event(),
'payment' => [],

View File

@ -67,6 +67,22 @@ class TriggeredActions extends AbstractService
$this->updated = false;
}
if($this->request->has('save_default_footer') && $this->request->input('save_default_footer') == 'true') {
$company = $this->invoice->company;
$settings = $company->settings;
$settings->invoice_footer = $this->invoice->footer;
$company->settings = $settings;
$company->save();
}
if($this->request->has('save_default_terms') && $this->request->input('save_default_terms') == 'true') {
$company = $this->invoice->company;
$settings = $company->settings;
$settings->invoice_terms = $this->invoice->terms;
$company->settings = $settings;
$company->save();
}
if($this->updated)
event('eloquent.updated: App\Models\Invoice', $this->invoice);

View File

@ -73,7 +73,8 @@ class Design extends BaseDesign
const PLAIN = 'plain';
const PLAYFUL = 'playful';
const CUSTOM = 'custom';
const CALM = 'calm';
const DELIVERY_NOTE = 'delivery_note';
const STATEMENT = 'statement';
const PURCHASE_ORDER = 'purchase_order';

View File

@ -52,6 +52,22 @@ class TriggeredActions extends AbstractService
// $this->purchase_order = $this->purchase_order->service()->handleCancellation()->save();
// }
if($this->request->has('save_default_footer') && $this->request->input('save_default_footer') == 'true') {
$company = $this->purchase_order->company;
$settings = $company->settings;
$settings->purchase_order_footer = $this->purchase_order->footer;
$company->settings = $settings;
$company->save();
}
if($this->request->has('save_default_terms') && $this->request->input('save_default_terms') == 'true') {
$company = $this->purchase_order->company;
$settings = $company->settings;
$settings->purchase_order_terms = $this->purchase_order->terms;
$company->settings = $settings;
$company->save();
}
return $this->purchase_order;
}

View File

@ -114,7 +114,7 @@ class Helpers
return '';
}
// 04-10-2022 Return Early if no reserved keywords are present, this is a very expenseive process
// 04-10-2022 Return Early if no reserved keywords are present, this is a very expensive process
$string_hit = false;
foreach ( [':MONTH',':YEAR',':QUARTER',':WEEK'] as $string )
@ -146,19 +146,19 @@ class Helpers
'%s %s %s',
Carbon::now()->subDays(7)->translatedFormat($entity->date_format()),
ctrans('texts.to'),
Carbon::now()->translatedFormat($entity->date_format())
Carbon::now()->subDays(1)->translatedFormat($entity->date_format())
),
':WEEK_AHEAD' => \sprintf(
'%s %s %s',
Carbon::now()->addDays(7)->translatedFormat($entity->date_format()),
Carbon::now()->addDays(6)->translatedFormat($entity->date_format()),
ctrans('texts.to'),
Carbon::now()->addDays(14)->translatedFormat($entity->date_format())
Carbon::now()->addDays(13)->translatedFormat($entity->date_format())
),
':WEEK' => \sprintf(
'%s %s %s',
Carbon::now()->translatedFormat($entity->date_format()),
Carbon::now()->subDays(7)->translatedFormat($entity->date_format()),
ctrans('texts.to'),
Carbon::now()->addDays(7)->translatedFormat($entity->date_format())
Carbon::now()->addDays(13)->translatedFormat($entity->date_format())
),
],
'raw' => [

View File

@ -124,6 +124,7 @@ class HtmlEngine
$data['$line_tax_labels'] = ['value' => $this->lineTaxLabels(), 'label' => ctrans('texts.taxes')];
$data['$line_tax_values'] = ['value' => $this->lineTaxValues(), 'label' => ctrans('texts.taxes')];
$data['$date'] = ['value' => $this->translateDate($this->entity->date, $this->client->date_format(), $this->client->locale()) ?: '&nbsp;', 'label' => ctrans('texts.date')];
$data['$status_logo'] = ['value' => '', 'label' => ''];
$data['$invoice.date'] = &$data['$date'];
$data['$invoiceDate'] = &$data['$date'];
@ -167,6 +168,10 @@ class HtmlEngine
$data['$invoice.project'] = &$data['$project.name'];
}
if($this->entity->status_id == 4) {
$data['$status_logo'] = ['value' => '<div class="stamp is-paid"> ' . ctrans('texts.paid') .'</div>', 'label' => ''];
}
if($this->entity->vendor) {
$data['$invoice.vendor'] = ['value' => $this->entity->vendor->present()->name(), 'label' => ctrans('texts.vendor_name')];
}

View File

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

View File

@ -0,0 +1,53 @@
<?php
use App\Models\Design;
use App\Services\PdfMaker\Design as PdfMakerDesign;
use App\Utils\Ninja;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
if (Ninja::isHosted()) {
$design = new Design();
$design->name = 'Calm';
$design->is_custom = false;
$design->design = '';
$design->is_active = true;
$design->save();
} elseif (Design::count() !== 0) {
$design = new Design();
$design->name = 'Calm';
$design->is_custom = false;
$design->design = '';
$design->is_active = true;
$design->save();
}
\Illuminate\Support\Facades\Artisan::call('ninja:design-update');
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
};

View File

@ -38,6 +38,7 @@ class DesignSeeder extends Seeder
['id' => 8, 'name' => 'Hipster', 'user_id' => null, 'company_id' => null, 'is_custom' => false, 'design' => '', 'is_active' => true],
['id' => 9, 'name' => 'Playful', 'user_id' => null, 'company_id' => null, 'is_custom' => false, 'design' => '', 'is_active' => true],
['id' => 10, 'name' => 'Tech', 'user_id' => null, 'company_id' => null, 'is_custom' => false, 'design' => '', 'is_active' => true],
['id' => 11, 'name' => 'Calm', 'user_id' => null, 'company_id' => null, 'is_custom' => false, 'design' => '', 'is_active' => true],
];
foreach ($designs as $design) {

View File

@ -4837,7 +4837,12 @@ $LANG = array(
'enable_applying_payments_later' => 'Enable Applying Payments Later',
'line_item_tax_rates' => 'Line Item Tax Rates',
'show_tasks_in_client_portal' => 'Show Tasks in Client Portal',
'notification_quote_expired_subject' => 'Quote :invoice has expired for :client',
'notification_quote_expired' => 'The following Quote :invoice for client :client and :amount has now expired.',
'auto_sync' => 'Auto Sync',
'refresh_accounts' => 'Refresh Accounts',
'upgrade_to_connect_bank_account' => 'Upgrade to Enterprise to connect your bank account',
'click_here_to_connect_bank_account' => 'Click here to connect your bank account',
);
return $LANG;

View File

@ -11,9 +11,9 @@ const RESOURCES = {
"favicon.png": "dca91c54388f52eded692718d5a98b8b",
"favicon.ico": "51636d3a390451561744c42188ccd628",
"flutter.js": "f85e6fb278b0fd20c349186fb46ae36d",
"/": "d2b918382ed83045e8e3854cae1edc55",
"/": "112f22769207bffb3936c08dec3ffa4d",
"manifest.json": "ef43d90e57aa7682d7e2cfba2f484a40",
"main.dart.js": "f6e5cc85c8e2f5aca83137308c219666",
"main.dart.js": "bddfba2a7d482fece1e7a9ff84429256",
"assets/AssetManifest.json": "759f9ef9973f7e26c2a51450b55bb9fa",
"assets/FontManifest.json": "087fb858dc3cbfbf6baf6a30004922f1",
"assets/NOTICES": "1a34e70168d56fad075adfb4bdbb20eb",

260857
public/main.dart.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

257225
public/main.foss.dart.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -101,6 +101,36 @@
#content .center {
text-align: center;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
</style>
</head>

View File

@ -80,6 +80,33 @@
#content .left {
text-align: left !important;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: relative;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: relative;
}
</style>
<!--[if gte mso 9]>

View File

@ -143,6 +143,36 @@
color: {{ $design == 'dark' ? '#ffffff' : '#000000' }} !important;
opacity: {{ $design == 'dark' ? '87%': '100%' }} !important;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
</style>
</head>

View File

@ -47,6 +47,7 @@
line-height: var(--line-height);
position: fixed;
top: 0;
width: 100%;
}
@ -224,7 +225,7 @@
position: fixed;
bottom: 0;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr;
gap: 15px;
color: white;
}
@ -239,6 +240,11 @@
padding-top: 0.5rem
}
[data-ref="footer_content"]{
padding-right: 2rem;
margin-right: 2rem;
}
table {
width: 100%;
}
@ -285,6 +291,36 @@
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/
@ -380,7 +416,7 @@ $entity_images
<div id="footer">
<div style="width: 100%;">
<p data-ref="total_table-footer">$entity_footer</p>
<p data-ref="footer_content">$entity_footer</p>
<script>
// Clear up space a bit, if [product-table, tasks-table, delivery-note-table] isn't present.
@ -402,6 +438,4 @@ $entity_images
});
</script>
</div>
<div> <!-- #2 column --> </div>
<div> <!-- #3 column --> </div>
</div>

View File

@ -276,6 +276,36 @@
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -0,0 +1,425 @@
<style id="style">
@import url($font_url);
:root {
--primary-color: $primary_color;
--secondary-color: $secondary_color;
--line-height: 1.6;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-family: $font_name, Helvetica, sans-serif;
font-size: "$font_size";
zoom: 80%;
}
html {
margin: 0;
padding-top: 1rem;
padding-left: 4rem;
padding-right: 4rem;
}
@page {
margin: 0 !important;
size: $page_size $page_layout;
}
p {
margin: 0;
padding: 0;
}
.header-wrapper {
display: grid;
grid-template-columns: 1fr 0.5fr;
line-height: var(--line-height);
}
.header-wrapper2 {
display: grid;
grid-template-columns: 1fr 0.5fr;
margin-top: 2rem;
min-width: 100%;
}
.company-logo {
max-width: 65%;
}
.client-and-entity-wrapper {
display: flex;
padding: 1rem;
border-top: 1px solid #d8d8d8;
border-bottom: 1px solid #d8d8d8;
}
.header-wrapper #company-address {
display: flex;
flex-direction: column;
line-height: var(--line-height);
}
.header-wrapper #entity-details {
margin-top: 0.5rem;
text-align: left;
width: 100%;
}
.header-wrapper #entity-details > tr,
.header-wrapper #entity-details th {
font-weight: normal;
padding-left: 0.9rem;
padding-top: 0.3rem;
padding-bottom: 0.3rem;
}
.header-wrapper
#entity-details
[data-element='entity-balance-due-label'],
.header-wrapper
#entity-details
[data-element='entity-balance-due'] {
background-color: #e6e6e6;
}
#client-details {
display: flex;
flex-direction: column;
line-height: var(--line-height);
}
[data-ref="table"] {
margin-top: 2rem;
min-width: 100%;
table-layout: fixed;
overflow-wrap: break-word;
}
.task-time-details {
display: block;
margin-top: 5px;
color: grey;
}
[data-ref="table"] > thead {
text-align: left;
}
[data-ref="table"] > thead > tr > th {
padding: 1rem;
background-color: #f5f5f5;
}
[data-ref="table"] > thead > tr > th:last-child {
text-align: right;
}
[data-ref="table"] > tbody > tr > td {
border-bottom: 1px solid #e6e6e6;
padding: 0.75rem;
}
[data-ref="table"] > tbody > tr > td:last-child {
text-align: right;
}
[data-ref="table"] > tbody > tr:nth-child(even) {
background-color: #f5f5f5;
}
#table-totals {
margin-top: 0.5rem;
display: grid;
grid-template-columns: 1.5fr 1fr;
padding-top: .5rem;
gap: 80px;
page-break-inside:auto;
overflow: visible !important;
font-weight: bold;
line-height: var(--line-height);
}
#table-totals .totals-table-right-side>* {
display: grid;
grid-template-columns: 1fr 1fr;
}
#table-totals>.totals-table-right-side>*> :nth-child(1) {
text-align: left;
margin-top: .25rem;
padding-left: 7px;
}
#table-totals>.totals-table-right-side> * > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(.25rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(.25rem * var(--tw-space-y-reverse));
}
#table-totals>.totals-table-right-side>*> :nth-child(2) {
text-align: right;
padding-right: 0px;
}
#entity-details {
text-align: left;
width: 100%;
}
#entity-details th {
font-weight:normal;
line-height: 1.5rem;
}
#table-totals
> *
[data-element='total-table-balance-due-label'],
#table-totals
> *
[data-element='total-table-balance-due'] {
font-weight: bold;
}
#table-totals > * > :last-child {
text-align: right;
padding-right: 1rem;
}
[data-ref="total_table-footer"] {
padding-left: 1rem;
padding-right: 1rem;
}
[data-ref="totals_table-outstanding"] {
color: var(--primary-color)
}
/** Markdown-specific styles. **/
[data-ref="table"] h3 {
font-size: 1rem;
margin-bottom: 0;
}
[data-ref="totals_table-outstanding-label"],
[data-ref="totals_table-outstanding"] {
background-color: #e6e6e6;
color: black;
padding-top: 7px;
padding-bottom: 7px;
padding-right: 7px;
}
[data-ref="statement-totals"] {
margin-top: 1rem;
text-align: right;
margin-right: .75rem;
}
[data-ref*=".line_total-td"] {
white-space: nowrap;
}
.repeating-footer,
.repeating-footer-space {
height: 150px;
}
.repeating-header {
position: fixed;
top: 0;
}
.repeating-footer {
position: fixed;
bottom: 0;
}
#header {
position: fixed;
top: 0;
}
#footer {
position: fixed;
bottom: 0;
border-top: 1px solid #000;
width: 82%;
min-height:100px;
padding-top: 0.5rem;
margin-top: 40px;
}
[data-element='product_table-product.description-td'], td {
min-width:100%;
max-width: 300px;
overflow-wrap: break-word;
}
[data-ref="total_table-public_notes"] { font-weight: normal; }
[data-ref="total_table-terms"] { font-weight: normal; }
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/
/* .company-logo { display: none } */
/* Hide company details */
/* # > * { display: none } */
/* Hide company address */
/* #company-address > * { display: none } */
/* Hide terms label */
/* [data-ref="total_table-terms-label"] { display: none } */
/* Hide totals table */
/* #table-totals { display: none } */
/* Hide totals table left side */
/* #table-totals div:first-child > * { display: none !important } */
/* Hide totals table right side */
/* .totals-table-right-side { display: none } */
/** For more info, please check our docs: https://invoiceninja.github.io **/
/** To find out selectors on your own: https://invoiceninja.github.io/docs/custom-fields/#snippets **/
</style>
<div id="body">
<table style="min-width: 100%">
<thead>
<tr>
<td>
<div class="repeating-header-space">&nbsp;</div>
</td>
</tr>
</thead>
<tbody>
<tr>
<td>
<div id="">
<div class="header-wrapper">
<div>
<img class="company-logo" src="$company.logo" alt="$company.name logo">
</div>
<div style="float:right; width:100%;">
<div id="company-details"></div>
<div id="company-address" style="margin-top:10px;"></div>
</div>
</div>
<div class="header-wrapper2">
<div id="client-details"></div>
<div id="vendor-details"></div>
<div>
<p class="entity-label" style="font-size:32px; font-weight: bold; color:$primary_color;">$entity_label</p>
<table id="entity-details" cellspacing="0" dir="ltr"></table>
</div>
</div>
<table id="product-table" cellspacing="0" data-ref="table"></table>
<table id="task-table" cellspacing="0" data-ref="table"></table>
<table id="delivery-note-table" cellspacing="0" data-ref="table"></table>
<table id="statement-invoice-table" cellspacing="0" data-ref="table"></table>
<div id="statement-invoice-table-totals" data-ref="statement-totals"></div>
<table id="statement-payment-table" cellspacing="0" data-ref="table"></table>
<div id="statement-payment-table-totals" data-ref="statement-totals"></div>
<table id="statement-aging-table" cellspacing="0" data-ref="table"></table>
<div id="statement-aging-table-totals" data-ref="statement-totals"></div>
<div id="table-totals" cellspacing="0"></div>
</div>
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>
<div class="repeating-footer-space">&nbsp;</div>
</td>
</tr>
</tfoot>
</table>
</div>
<div class="repeating-header" id="header"></div>
<div id="footer" style="">
<div style="width: 100%;">
<p data-ref="total_table-footer">$entity_footer</p>
<script>
// Clear up space a bit, if [product-table, tasks-table, delivery-note-table] isn't present.
document.addEventListener('DOMContentLoaded', () => {
let tables = [
'product-table', 'task-table', 'delivery-note-table',
'statement-invoice-table', 'statement-payment-table', 'statement-aging-table-totals',
'statement-invoice-table-totals', 'statement-payment-table-totals', 'statement-aging-table',
'vendor-details', 'client-details'
];
tables.forEach((tableIdentifier) => {
console.log(document.getElementById(tableIdentifier));
document.getElementById(tableIdentifier)?.childElementCount === 0
? document.getElementById(tableIdentifier).style.setProperty('display', 'none', 'important')
: '';
});
});
</script>
</div>
</div>
<script>
// Clear up space a bit, if [product-table, tasks-table, delivery-note-table] isn't present.
document.addEventListener('DOMContentLoaded', () => {
let tables = [
'product-table', 'task-table', 'delivery-note-table',
'statement-invoice-table', 'statement-payment-table', 'statement-aging-table-totals',
'statement-invoice-table-totals', 'statement-payment-table-totals', 'statement-aging-table',
'client-details','vendor-details', 'swiss-qr'
];
tables.forEach((tableIdentifier) => {
console.log(document.getElementById(tableIdentifier));
document.getElementById(tableIdentifier)?.childElementCount === 0
? document.getElementById(tableIdentifier).style.setProperty('display', 'none', 'important')
: '';
});
});
</script>

View File

@ -257,6 +257,36 @@
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -229,6 +229,35 @@
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -233,7 +233,36 @@
max-width: 300px;
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -251,6 +251,35 @@
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -278,6 +278,35 @@
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -221,6 +221,35 @@
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -293,6 +293,36 @@
max-width: 300px;
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
@ -323,7 +353,7 @@
/** For more info, please check our docs: https://invoiceninja.github.io **/
/** To find out selectors on your own: https://invoiceninja.github.io/docs/custom-fields/#snippets **/
</style>
<div id="body">
<table style="min-width: 100%">
<thead>
<tr>
@ -335,7 +365,6 @@
<tbody>
<tr>
<td>
<div id="body">
<div class="header-wrapper">
<div>
<img class="company-logo" src="$company.logo" alt="$company.name logo">
@ -367,8 +396,7 @@
<div id="statement-payment-table-totals" data-ref="statement-totals"></div>
<table id="statement-aging-table" cellspacing="0" data-ref="table"></table>
<div id="statement-aging-table-totals" data-ref="statement-totals"></div>
<div id="table-totals" cellspacing="0"></div>
</div>
<div id="table-totals" cellspacing="0"></div>
</td>
</tr>
</tbody>
@ -380,7 +408,7 @@
</tr>
</tfoot>
</table>
</div>
<div class="repeating-header">
<div id="header">
<div style="background-color: #00968B"><!-- 1 --></div>

View File

@ -257,7 +257,36 @@
max-width: 300px;
overflow-wrap: break-word;
}
.stamp {
transform: rotate(12deg);
color: #555;
font-size: 3rem;
font-weight: 700;
border: 0.25rem solid #555;
display: inline-block;
padding: 0.25rem 1rem;
text-transform: uppercase;
border-radius: 1rem;
font-family: 'Courier';
mix-blend-mode: multiply;
z-index:200 !important;
position: fixed;
text-align: center;
}
.is-paid {
color: #D23;
border: 1rem double #D23;
transform: rotate(-5deg);
font-size: 6rem;
font-family: "Open sans", Helvetica, Arial, sans-serif;
border-radius: 0;
padding: 0.5rem;
opacity: 0.2;
z-index:200 !important;
position: fixed;
}
/** Useful snippets, uncomment to enable. **/
/** Hide company logo **/

View File

@ -163,7 +163,7 @@
@yield('footer')
@stack('footer')
@if((bool) \App\Utils\Ninja::isSelfHost() && !empty($client->getSetting('portal_custom_footer')))
@if($company && $company->account->isPaid() && !empty($client->getSetting('portal_custom_footer')))
<div class="py-1 text-sm text-center text-white bg-primary">
{!! $client->getSetting('portal_custom_footer') !!}
</div>

View File

@ -164,7 +164,7 @@
@yield('footer')
@stack('footer')
@if((bool) \App\Utils\Ninja::isSelfHost() && !empty($settings->portal_custom_footer))
@if($company && $company->account->isPaid() && !empty($settings->portal_custom_footer))
<div class="py-1 text-sm text-center text-white bg-primary">
{!! $settings->portal_custom_footer !!}
</div>

View File

@ -58,6 +58,4 @@ Route::get('yodlee/onboard/{token}', [YodleeController::class, 'auth'])->name('y
Route::get('checkout/3ds_redirect/{company_key}/{company_gateway_id}/{hash}', [Checkout3dsController::class, 'index'])->middleware('domain_db')->name('checkout.3ds_redirect');
Route::get('mollie/3ds_redirect/{company_key}/{company_gateway_id}/{hash}', [Mollie3dsController::class, 'index'])->middleware('domain_db')->name('mollie.3ds_redirect');
Route::get('gocardless/ibp_redirect/{company_key}/{company_gateway_id}/{hash}', [GoCardlessController::class, 'ibpRedirect'])->middleware('domain_db')->name('gocardless.ibp_redirect');
Route::get('.well-known/apple-developer-merchantid-domain-association', [ApplePayDomainController::class, 'showAppleMerchantId']);
Route::fallback([BaseController::class, 'reactCatch']);
Route::get('.well-known/apple-developer-merchantid-domain-association', [ApplePayDomainController::class, 'showAppleMerchantId']);

View File

@ -11,6 +11,7 @@
namespace Tests\Feature;
use App\Factory\InvoiceItemFactory;
use App\Factory\InvoiceToRecurringInvoiceFactory;
use App\Factory\RecurringInvoiceToInvoiceFactory;
use App\Models\Client;
@ -51,6 +52,53 @@ class RecurringInvoiceTest extends TestCase
$this->makeTestData();
}
public function testPostRecurringInvoiceWithPlaceholderVariables()
{
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 10;
$item->task_id = $this->encodePrimaryKey($this->task->id);
$item->expense_id = $this->encodePrimaryKey($this->expense->id);
$item->notes = "Hello this is the month of :MONTH";
$line_items[] = $item;
$data = [
'frequency_id' => 1,
'status_id' => 1,
'discount' => 0,
'is_amount_discount' => 1,
'po_number' => '3434343',
'public_notes' => 'notes',
'is_deleted' => 0,
'custom_value1' => 0,
'custom_value2' => 0,
'custom_value3' => 0,
'custom_value4' => 0,
'status' => 1,
'client_id' => $this->encodePrimaryKey($this->client->id),
'line_items' => $line_items,
'remaining_cycles' => -1,
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->post('/api/v1/recurring_invoices/', $data)
->assertStatus(200);
$arr = $response->json();
$this->assertEquals(RecurringInvoice::STATUS_DRAFT, $arr['data']['status_id']);
$notes = end($arr['data']['line_items'])['notes'];
$this->assertTrue(str_contains($notes, ':MONTH'));
}
public function testPostRecurringInvoice()
{
$data = [

View File

@ -48,27 +48,4 @@ class AutoBillInvoiceTest extends TestCase
$this->assertEquals($this->client->fresh()->credit_balance, 0);
}
// public function testAutoBillSetOffFunctionality()
// {
// $settings = $this->company->settings;
// $settings->use_credits_payment = 'off';
// $this->company->settings = $settings;
// $this->company->save();
// $this->assertEquals($this->client->balance, 10);
// $this->assertEquals($this->client->paid_to_date, 0);
// $this->assertEquals($this->client->credit_balance, 10);
// $this->invoice->service()->markSent()->autoBill()->save();
// $this->assertNotNull($this->invoice->payments());
// $this->assertEquals(0, $this->invoice->payments()->sum('payments.amount'));
// $this->assertEquals($this->client->balance, 10);
// $this->assertEquals($this->client->paid_to_date, 0);
// $this->assertEquals($this->client->credit_balance, 10);
// }
}

View File

@ -0,0 +1,86 @@
<?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\Unit;
use App\DataMapper\InvoiceItem;
use App\Models\Invoice;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\MockAccountData;
use Tests\TestCase;
/**
* @test
*/
class LateFeeTest extends TestCase
{
use DatabaseTransactions;
use MockAccountData;
protected function setUp() :void
{
parent::setUp();
$this->makeTestData();
}
public function testLateFeeBalances()
{
$this->assertEquals(10, $this->client->balance);
$this->assertEquals(10, $this->invoice->balance);
$this->invoice = $this->setLateFee($this->invoice, 5, 0);
$this->assertEquals(15, $this->client->fresh()->balance);
$this->assertEquals(15, $this->invoice->fresh()->balance);
}
private function setLateFee($invoice, $amount, $percent) :Invoice
{
$temp_invoice_balance = $invoice->balance;
if ($amount <= 0 && $percent <= 0) {
return $invoice;
}
$fee = $amount;
if ($invoice->partial > 0) {
$fee += round($invoice->partial * $percent / 100, 2);
} else {
$fee += round($invoice->balance * $percent / 100, 2);
}
$invoice_item = new InvoiceItem;
$invoice_item->type_id = '5';
$invoice_item->product_key = trans('texts.fee');
$invoice_item->notes = ctrans('texts.late_fee_added', ['date' => now()]);
$invoice_item->quantity = 1;
$invoice_item->cost = $fee;
$invoice_items = $invoice->line_items;
$invoice_items[] = $invoice_item;
$invoice->line_items = $invoice_items;
/**Refresh Invoice values*/
$invoice = $invoice->calc()->getInvoice();
$invoice->client->service()->updateBalance($invoice->balance - $temp_invoice_balance)->save();
$invoice->ledger()->updateInvoiceBalance($invoice->balance - $temp_invoice_balance, "Late Fee Adjustment for invoice {$invoice->number}");
return $invoice;
}
}