mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-03-02 22:57:00 +00:00
@@ -15,7 +15,7 @@ Invoice Ninja Version 5 is here! We've taken the best parts of version 4 and add
|
||||
- [Hosted](https://www.invoiceninja.com): Our hosted version is a Software as a Service (SaaS) solution. You're up and running in under 5 minutes, with no need to worry about hosting or server infrastructure.
|
||||
- [Self-Hosted](https://www.invoiceninja.org): For those who prefer to manage their own hosting and server infrastructure. This version gives you full control and flexibility.
|
||||
|
||||
All Pro and Enterprise features from the hosted app are included in the source-available code. We offer a $30 per year white-label license to remove the Invoice Ninja branding from client-facing parts of the app.
|
||||
All Pro and Enterprise features from the hosted app are included in the source-available code. We offer a $40 per year white-label license to remove the Invoice Ninja branding from client-facing parts of the app.
|
||||
|
||||
#### Get social with us
|
||||
|
||||
@@ -58,6 +58,9 @@ All Pro and Enterprise features from the hosted app are included in the source-a
|
||||
* [Stripe](https://stripe.com/)
|
||||
* [Postmark](https://postmarkapp.com/)
|
||||
|
||||
### SDKs available for easier API consumption
|
||||
* [Go SDK](https://github.com/AshkanYarmoradi/go-invoice-ninja)
|
||||
|
||||
## [Advanced] Quick Hosting Setup
|
||||
|
||||
In addition to the official [Invoice Ninja - Self-Hosted Installation Guide](https://invoiceninja.github.io/en/self-host-installation/) we have a few commands for you.
|
||||
|
||||
@@ -1 +1 @@
|
||||
5.12.38
|
||||
5.12.50
|
||||
@@ -30,10 +30,9 @@ class QuickbooksSettingsCast implements CastsAttributes
|
||||
public function set($model, string $key, $value, array $attributes)
|
||||
{
|
||||
if ($value instanceof QuickbooksSettings) {
|
||||
return json_encode(get_object_vars($value));
|
||||
return json_encode($value->toArray());
|
||||
}
|
||||
|
||||
return null;
|
||||
// return json_encode($value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ use App\Jobs\EDocument\EInvoicePullDocs;
|
||||
use App\Jobs\Cron\InvoiceTaxSummary;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use App\Jobs\Invoice\InvoiceCheckLateWebhook;
|
||||
use App\Jobs\Invoice\InvoiceCheckOverdue;
|
||||
use App\Jobs\Subscription\CleanStaleInvoiceOrder;
|
||||
use App\PaymentDrivers\Rotessa\Jobs\TransactionReport;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
@@ -130,7 +131,10 @@ class Kernel extends ConsoleKernel
|
||||
$schedule->job(new AutoBillCron())->dailyAt('06:20')->withoutOverlapping()->name('auto-bill-job')->onOneServer();
|
||||
|
||||
/* Fires webhooks for overdue Invoice */
|
||||
$schedule->job(new InvoiceCheckLateWebhook())->dailyAt('07:00')->withoutOverlapping()->name('invoice-overdue-job')->onOneServer();
|
||||
$schedule->job(new InvoiceCheckLateWebhook())->dailyAt('07:00')->withoutOverlapping()->name('invoice-overdue-webhook-job')->onOneServer();
|
||||
|
||||
/* Fires notifications for overdue Invoice (respects company timezone) */
|
||||
$schedule->job(new InvoiceCheckOverdue())->hourly()->withoutOverlapping()->name('invoice-overdue-notification-job')->onOneServer();
|
||||
|
||||
/* Pulls in bank transactions from third party services */
|
||||
$schedule->job(new BankTransactionSync())->twiceDaily(1, 13)->withoutOverlapping()->name('bank-trans-sync-job')->onOneServer();
|
||||
|
||||
125
app/DataMapper/IncomeAccountMap.php
Normal file
125
app/DataMapper/IncomeAccountMap.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\DataMapper;
|
||||
|
||||
use App\Models\Product;
|
||||
|
||||
/**
|
||||
* Holds the QuickBooks income account ID per product type.
|
||||
*
|
||||
* Child properties map Product::PRODUCT_TYPE_* to QuickBooks account ID (string|null).
|
||||
* Initialized with no values; call setAccountId() or assign properties to populate.
|
||||
*/
|
||||
class IncomeAccountMap
|
||||
{
|
||||
public ?string $physical = null;
|
||||
|
||||
public ?string $service = null;
|
||||
|
||||
public ?string $digital = null;
|
||||
|
||||
public ?string $shipping = null;
|
||||
|
||||
public ?string $exempt = null;
|
||||
|
||||
public ?string $reduced_tax = null;
|
||||
|
||||
public ?string $override_tax = null;
|
||||
|
||||
public ?string $zero_rated = null;
|
||||
|
||||
public ?string $reverse_tax = null;
|
||||
|
||||
public ?string $intra_community = null;
|
||||
|
||||
/**
|
||||
* Initialize from attributes array.
|
||||
* Accepts array with int keys (Product::PRODUCT_TYPE_*) or string keys (property names).
|
||||
*/
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
$this->physical = $attributes['physical'] ?? null;
|
||||
$this->service = $attributes['service'] ?? null;
|
||||
$this->digital = $attributes['digital'] ?? null;
|
||||
$this->shipping = $attributes['shipping'] ?? null;
|
||||
$this->exempt = $attributes['exempt'] ?? null;
|
||||
$this->reduced_tax = $attributes['reduced_tax'] ?? null;
|
||||
$this->override_tax = $attributes['override_tax'] ?? null;
|
||||
$this->zero_rated = $attributes['zero_rated'] ?? null;
|
||||
$this->reverse_tax = $attributes['reverse_tax'] ?? null;
|
||||
$this->intra_community = $attributes['intra_community'] ?? null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* getAccountId
|
||||
*
|
||||
* Gets the Quickbooks Income Account ID for a given product tax_id.
|
||||
* @param string $product_tax_id
|
||||
* @return string|null
|
||||
*/
|
||||
public function getAccountId(?string $product_tax_id): ?string
|
||||
{
|
||||
/**
|
||||
* @var string|null $prop
|
||||
*
|
||||
* Translates "2" => "service"
|
||||
*
|
||||
* */
|
||||
$prop = $this->getPropertyName($product_tax_id);
|
||||
|
||||
return $prop ? $this->{$prop} : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* getPropertyName
|
||||
*
|
||||
* Tranlates the $item->tax_id => property name.
|
||||
*
|
||||
* Gets the property name for a given product tax_id.
|
||||
* @param int|string $key
|
||||
* @return string|null
|
||||
*/
|
||||
private function getPropertyName(int|string $key): ?string
|
||||
{
|
||||
return match ((string)$key) {
|
||||
(string)Product::PRODUCT_TYPE_PHYSICAL => 'physical',
|
||||
(string)Product::PRODUCT_TYPE_SERVICE => 'service',
|
||||
(string)Product::PRODUCT_TYPE_DIGITAL => 'digital',
|
||||
(string)Product::PRODUCT_TYPE_SHIPPING => 'shipping',
|
||||
(string)Product::PRODUCT_TYPE_EXEMPT => 'exempt',
|
||||
(string)Product::PRODUCT_TYPE_REDUCED_TAX => 'reduced_tax',
|
||||
(string)Product::PRODUCT_TYPE_OVERRIDE_TAX => 'override_tax',
|
||||
(string)Product::PRODUCT_TYPE_ZERO_RATED => 'zero_rated',
|
||||
(string)Product::PRODUCT_TYPE_REVERSE_TAX => 'reverse_tax',
|
||||
(string)Product::PRODUCT_INTRA_COMMUNITY => 'intra_community',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'physical' => $this->physical,
|
||||
'service' => $this->service,
|
||||
'digital' => $this->digital,
|
||||
'shipping' => $this->shipping,
|
||||
'exempt' => $this->exempt,
|
||||
'reduced_tax' => $this->reduced_tax,
|
||||
'override_tax' => $this->override_tax,
|
||||
'zero_rated' => $this->zero_rated,
|
||||
'reverse_tax' => $this->reverse_tax,
|
||||
'intra_community' => $this->intra_community,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class QuickbooksSettings implements Castable
|
||||
|
||||
public string $baseURL;
|
||||
|
||||
public string $companyName;
|
||||
|
||||
public QuickbooksSync $settings;
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
@@ -42,6 +44,7 @@ class QuickbooksSettings implements Castable
|
||||
$this->accessTokenExpiresAt = $attributes['accessTokenExpiresAt'] ?? 0;
|
||||
$this->refreshTokenExpiresAt = $attributes['refreshTokenExpiresAt'] ?? 0;
|
||||
$this->baseURL = $attributes['baseURL'] ?? '';
|
||||
$this->companyName = $attributes['companyName'] ?? '';
|
||||
$this->settings = new QuickbooksSync($attributes['settings'] ?? []);
|
||||
}
|
||||
|
||||
@@ -55,4 +58,43 @@ class QuickbooksSettings implements Castable
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'accessTokenKey' => $this->accessTokenKey,
|
||||
'refresh_token' => $this->refresh_token,
|
||||
'realmID' => $this->realmID,
|
||||
'accessTokenExpiresAt' => $this->accessTokenExpiresAt,
|
||||
'refreshTokenExpiresAt' => $this->refreshTokenExpiresAt,
|
||||
'baseURL' => $this->baseURL,
|
||||
'companyName' => $this->companyName,
|
||||
'settings' => $this->settings->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this QuickbooksSettings instance represents actual data or is just a default empty object.
|
||||
*
|
||||
* @return bool True if this has actual QuickBooks connection data, false if it's just defaults
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
// If accessTokenKey is set, we have a connection
|
||||
return !empty($this->accessTokenKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this QuickbooksSettings instance is empty (default values only).
|
||||
*
|
||||
* @return bool True if this is an empty/default instance
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return empty($this->accessTokenKey)
|
||||
&& empty($this->refresh_token)
|
||||
&& empty($this->realmID)
|
||||
&& $this->accessTokenExpiresAt === 0
|
||||
&& $this->refreshTokenExpiresAt === 0
|
||||
&& empty($this->baseURL);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,15 @@
|
||||
|
||||
namespace App\DataMapper;
|
||||
|
||||
use App\Models\Product;
|
||||
|
||||
/**
|
||||
* QuickbooksSync.
|
||||
*
|
||||
* Product type to income account mapping:
|
||||
* Keys are Product::PRODUCT_TYPE_* constants (int). Values are QuickBooks account IDs (string|null).
|
||||
* Example: [Product::PRODUCT_TYPE_SERVICE => '123', Product::PRODUCT_TYPE_PHYSICAL => '456']
|
||||
* Null values indicate the account has not been configured for that product type.
|
||||
*/
|
||||
class QuickbooksSync
|
||||
{
|
||||
@@ -35,9 +42,11 @@ class QuickbooksSync
|
||||
|
||||
public QuickbooksSyncMap $expense;
|
||||
|
||||
public string $default_income_account = '';
|
||||
|
||||
public string $default_expense_account = '';
|
||||
/**
|
||||
* QuickBooks income account ID per product type.
|
||||
* Use getAccountId(int $productTypeId) or the typed properties (physical, service, etc.).
|
||||
*/
|
||||
public IncomeAccountMap $income_account_map;
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
@@ -50,7 +59,23 @@ class QuickbooksSync
|
||||
$this->product = new QuickbooksSyncMap($attributes['product'] ?? []);
|
||||
$this->payment = new QuickbooksSyncMap($attributes['payment'] ?? []);
|
||||
$this->expense = new QuickbooksSyncMap($attributes['expense'] ?? []);
|
||||
$this->default_income_account = $attributes['default_income_account'] ?? '';
|
||||
$this->default_expense_account = $attributes['default_expense_account'] ?? '';
|
||||
$this->income_account_map = new IncomeAccountMap($attributes['income_account_map'] ?? []);
|
||||
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'client' => $this->client->toArray(),
|
||||
'vendor' => $this->vendor->toArray(),
|
||||
'invoice' => $this->invoice->toArray(),
|
||||
'sales' => $this->sales->toArray(),
|
||||
'quote' => $this->quote->toArray(),
|
||||
'purchase_order' => $this->purchase_order->toArray(),
|
||||
'product' => $this->product->toArray(),
|
||||
'payment' => $this->payment->toArray(),
|
||||
'expense' => $this->expense->toArray(),
|
||||
'income_account_map' => $this->income_account_map->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,24 @@ use App\Enum\SyncDirection;
|
||||
*/
|
||||
class QuickbooksSyncMap
|
||||
{
|
||||
public SyncDirection $direction = SyncDirection::BIDIRECTIONAL;
|
||||
public SyncDirection $direction = SyncDirection::NONE;
|
||||
|
||||
public function __construct(array $attributes = [])
|
||||
{
|
||||
$this->direction = isset($attributes['direction'])
|
||||
? SyncDirection::from($attributes['direction'])
|
||||
: SyncDirection::BIDIRECTIONAL;
|
||||
: SyncDirection::NONE;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
// Ensure direction is always returned as a string value, not the enum object
|
||||
$directionValue = $this->direction instanceof \App\Enum\SyncDirection
|
||||
? $this->direction->value
|
||||
: (string) $this->direction;
|
||||
|
||||
return [
|
||||
'direction' => $directionValue,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ class PayPalBalanceAffecting
|
||||
|
||||
|
||||
|
||||
// $csv = Reader::createFromString($csvFile);
|
||||
// $csv = Reader::fromString($csvFile);
|
||||
// // $csvdelimiter = self::detectDelimiter($csvfile);
|
||||
// $csv->setDelimiter(",");
|
||||
// $stmt = new Statement();
|
||||
|
||||
@@ -17,4 +17,5 @@ enum SyncDirection: string
|
||||
case PUSH = 'push'; // only creates and updates records created by Invoice Ninja.
|
||||
case PULL = 'pull'; // creates and updates record from QB.
|
||||
case BIDIRECTIONAL = 'bidirectional'; // creates and updates records created by Invoice Ninja and from QB.
|
||||
case NONE = 'none'; // no sync.
|
||||
}
|
||||
|
||||
54
app/Events/Quote/QuoteWasRejected.php
Normal file
54
app/Events/Quote/QuoteWasRejected.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Events\Quote;
|
||||
|
||||
use App\Models\ClientContact;
|
||||
use App\Models\Company;
|
||||
use App\Models\Quote;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class QuoteWasRejected
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithSockets;
|
||||
use SerializesModels;
|
||||
|
||||
public $contact;
|
||||
|
||||
public $quote;
|
||||
|
||||
public $company;
|
||||
|
||||
public $event_vars;
|
||||
|
||||
public $notes;
|
||||
|
||||
/**
|
||||
* Create a new event instance.
|
||||
*
|
||||
* @param ClientContact $contact
|
||||
* @param Quote $quote
|
||||
* @param Company $company
|
||||
* @param array $event_vars
|
||||
*/
|
||||
public function __construct(ClientContact $contact, Quote $quote, Company $company, string $notes, array $event_vars)
|
||||
{
|
||||
$this->contact = $contact;
|
||||
$this->quote = $quote;
|
||||
$this->company = $company;
|
||||
$this->notes = $notes;
|
||||
$this->event_vars = $event_vars;
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,7 @@ class BaseExport
|
||||
'name' => 'client.name',
|
||||
"currency" => "client.currency_id",
|
||||
"invoice_number" => "invoice.number",
|
||||
"subtotal" => "invoice.subtotal",
|
||||
"amount" => "invoice.amount",
|
||||
"balance" => "invoice.balance",
|
||||
"paid_to_date" => "invoice.paid_to_date",
|
||||
@@ -259,6 +260,7 @@ class BaseExport
|
||||
'terms' => 'purchase_order.terms',
|
||||
'total_taxes' => 'purchase_order.total_taxes',
|
||||
'currency_id' => 'purchase_order.currency_id',
|
||||
'subtotal' => 'purchase_order.subtotal',
|
||||
];
|
||||
|
||||
protected array $product_report_keys = [
|
||||
@@ -348,6 +350,7 @@ class BaseExport
|
||||
'tax_rate1' => 'quote.tax_rate1',
|
||||
'tax_rate2' => 'quote.tax_rate2',
|
||||
'tax_rate3' => 'quote.tax_rate3',
|
||||
'subtotal' => 'quote.subtotal',
|
||||
];
|
||||
|
||||
protected array $credit_report_keys = [
|
||||
@@ -382,6 +385,7 @@ class BaseExport
|
||||
"tax_amount" => "credit.total_taxes",
|
||||
"assigned_user" => "credit.assigned_user_id",
|
||||
"user" => "credit.user_id",
|
||||
'subtotal' => 'credit.subtotal',
|
||||
];
|
||||
|
||||
protected array $payment_report_keys = [
|
||||
|
||||
@@ -251,6 +251,9 @@ class CreditExport extends BaseExport
|
||||
$entity['credit.user_id'] = $credit->user ? $credit->user->present()->name() : ''; //@phpstan-ignore-line
|
||||
}
|
||||
|
||||
if (in_array('credit.subtotal', $this->input['report_keys'])) {
|
||||
$entity['credit.subtotal'] = $credit->calc()->getSubTotal();
|
||||
}
|
||||
return $entity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +276,11 @@ class ExpenseExport extends BaseExport
|
||||
$total_tax_amount = ($this->calcInclusiveLineTax($expense->tax_rate1 ?? 0, $expense->amount, $precision)) + ($this->calcInclusiveLineTax($expense->tax_rate2 ?? 0, $expense->amount, $precision)) + ($this->calcInclusiveLineTax($expense->tax_rate3 ?? 0, $expense->amount, $precision));
|
||||
$entity['expense.net_amount'] = round(($expense->amount - round($total_tax_amount, $precision)), $precision);
|
||||
} else {
|
||||
$total_tax_amount = ($expense->amount * (($expense->tax_rate1 ?? 0) / 100)) + ($expense->amount * (($expense->tax_rate2 ?? 0) / 100)) + ($expense->amount * (($expense->tax_rate3 ?? 0) / 100));
|
||||
$tax_amount1 = $expense->amount * (($expense->tax_rate1 ?? 0) / 100);
|
||||
$tax_amount2 = $expense->amount * (($expense->tax_rate2 ?? 0) / 100);
|
||||
$tax_amount3 = $expense->amount * (($expense->tax_rate3 ?? 0) / 100);
|
||||
|
||||
$total_tax_amount = round($tax_amount1, $precision) + round($tax_amount2, $precision) + round($tax_amount3, $precision);
|
||||
$entity['expense.net_amount'] = round($expense->amount, $precision);
|
||||
$entity['expense.amount'] = round($expense->amount, $precision) + $total_tax_amount;
|
||||
}
|
||||
|
||||
@@ -249,7 +249,10 @@ class InvoiceExport extends BaseExport
|
||||
|
||||
if (in_array('invoice.user_id', $this->input['report_keys'])) {
|
||||
$entity['invoice.user_id'] = $invoice->user ? $invoice->user->present()->name() : ''; // @phpstan-ignore-line
|
||||
}
|
||||
|
||||
if (in_array('invoice.subtotal', $this->input['report_keys'])) {
|
||||
$entity['invoice.subtotal'] = $invoice->calc()->getSubTotal();
|
||||
}
|
||||
|
||||
return $entity;
|
||||
|
||||
@@ -282,6 +282,11 @@ class InvoiceItemExport extends BaseExport
|
||||
$entity['invoice.project'] = $invoice->project ? $invoice->project->name : '';// @phpstan-ignore-line
|
||||
}
|
||||
|
||||
if (in_array('invoice.subtotal', $this->input['report_keys'])) {
|
||||
$entity['invoice.subtotal'] = $invoice->calc()->getSubTotal();
|
||||
}
|
||||
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,9 @@ class PurchaseOrderExport extends BaseExport
|
||||
$entity['purchase_order.assigned_user_id'] = $purchase_order->assigned_user ? $purchase_order->assigned_user->present()->name() : '';
|
||||
}
|
||||
|
||||
if (in_array('purchase_order.subtotal', $this->input['report_keys'])) {
|
||||
$entity['purchase_order.subtotal'] = $purchase_order->calc()->getSubTotal();
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
@@ -249,6 +249,10 @@ class PurchaseOrderItemExport extends BaseExport
|
||||
$entity['purchase_order.assigned_user_id'] = $purchase_order->assigned_user ? $purchase_order->assigned_user->present()->name() : '';
|
||||
}
|
||||
|
||||
if (in_array('purchase_order.subtotal', $this->input['report_keys'])) {
|
||||
$entity['purchase_order.subtotal'] = $purchase_order->calc()->getSubTotal();
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,9 @@ class QuoteExport extends BaseExport
|
||||
$entity['quote.user_id'] = $quote->user ? $quote->user->present()->name() : '';
|
||||
}
|
||||
|
||||
if (in_array('quote.subtotal', $this->input['report_keys'])) {
|
||||
$entity['quote.subtotal'] = $quote->calc()->getSubTotal();
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
@@ -249,6 +249,9 @@ class QuoteItemExport extends BaseExport
|
||||
}
|
||||
|
||||
|
||||
if (in_array('quote.subtotal', $this->input['report_keys'])) {
|
||||
$entity['quote.subtotal'] = $quote->calc()->getSubTotal();
|
||||
}
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class RecurringInvoiceItemExport extends BaseExport
|
||||
if (count($this->input['report_keys']) == 0) {
|
||||
$this->force_keys = true;
|
||||
$this->input['report_keys'] = array_values($this->mergeItemsKeys('recurring_invoice_report_keys'));
|
||||
nlog($this->input['report_keys']);
|
||||
// nlog($this->input['report_keys']);
|
||||
}
|
||||
|
||||
$this->input['report_keys'] = array_merge($this->input['report_keys'], array_diff($this->forced_client_fields, $this->input['report_keys']));
|
||||
|
||||
@@ -69,7 +69,8 @@ class RecurringInvoiceToInvoiceFactory
|
||||
$invoice->design_id = $recurring_invoice->design_id;
|
||||
$invoice->e_invoice = self::transformEInvoice($recurring_invoice);
|
||||
$invoice->vendor_id = $recurring_invoice->vendor_id;
|
||||
|
||||
$invoice->location_id = $recurring_invoice->location_id;
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
@@ -113,13 +114,19 @@ class RecurringInvoiceToInvoiceFactory
|
||||
|
||||
$end_date = $end_date->format('Y-m-d');
|
||||
|
||||
$einvoice = new \InvoiceNinja\EInvoice\Models\Peppol\Invoice();
|
||||
// $einvoice = new \InvoiceNinja\EInvoice\Models\Peppol\Invoice();
|
||||
// $ip = new \InvoiceNinja\EInvoice\Models\Peppol\PeriodType\InvoicePeriod();
|
||||
// $ip->StartDate = new \DateTime($start_date);
|
||||
// $ip->EndDate = new \DateTime($end_date);
|
||||
// $einvoice->InvoicePeriod = [$ip];
|
||||
|
||||
$ip = new \InvoiceNinja\EInvoice\Models\Peppol\PeriodType\InvoicePeriod();
|
||||
$ip->StartDate = new \DateTime($start_date);
|
||||
$ip->EndDate = new \DateTime($end_date);
|
||||
$einvoice->InvoicePeriod = [$ip];
|
||||
// 2026-01-12 - To prevent storing datetime objects in the database, we manually build the InvoicePeriod object
|
||||
$einvoice = new \stdClass();
|
||||
|
||||
$invoice_period = new \stdClass();
|
||||
$invoice_period->StartDate = $start_date;
|
||||
$invoice_period->EndDate = $end_date;
|
||||
$einvoice->InvoicePeriod = [$invoice_period];
|
||||
|
||||
$stub = new \stdClass();
|
||||
$stub->Invoice = $einvoice;
|
||||
|
||||
@@ -287,10 +287,50 @@ class InvoiceFilters extends QueryFilters
|
||||
|
||||
if ($sort_col[0] == 'client_id') {
|
||||
|
||||
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
|
||||
->orderBy(\App\Models\Client::select('name')
|
||||
->whereColumn('clients.id', 'invoices.client_id'), $dir);
|
||||
|
||||
// 2026-01-21: Original sort by client name that is not optimal when clients.name is empty.
|
||||
// return $this->builder->orderByRaw('client_id IS NULL')
|
||||
// ->orderBy(\App\Models\Client::select('name')
|
||||
// ->whereColumn('clients.id', 'recurring_invoices.client_id')
|
||||
// ->limit(1), $dir);
|
||||
|
||||
|
||||
/**
|
||||
* future options for order by raw if this is not performant:
|
||||
*
|
||||
COALESCE(
|
||||
NULLIF((SELECT name FROM clients WHERE clients.id = invoices.client_id LIMIT 1), ''),
|
||||
(SELECT email FROM client_contacts
|
||||
WHERE client_contacts.client_id = invoices.client_id
|
||||
AND client_contacts.email IS NOT NULL
|
||||
ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC
|
||||
LIMIT 1),
|
||||
'No Contact Set'
|
||||
) " . $dir
|
||||
|
||||
*/
|
||||
|
||||
return $this->builder
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN CHAR_LENGTH((SELECT name FROM clients WHERE clients.id = invoices.client_id LIMIT 1)) > 1
|
||||
THEN (SELECT name FROM clients WHERE clients.id = invoices.client_id LIMIT 1)
|
||||
WHEN CHAR_LENGTH(CONCAT(
|
||||
COALESCE((SELECT first_name FROM client_contacts WHERE client_contacts.client_id = invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), ''),
|
||||
COALESCE((SELECT last_name FROM client_contacts WHERE client_contacts.client_id = invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), '')
|
||||
)) >= 1
|
||||
THEN TRIM(CONCAT(
|
||||
COALESCE((SELECT first_name FROM client_contacts WHERE client_contacts.client_id = invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), ''),
|
||||
' ',
|
||||
COALESCE((SELECT last_name FROM client_contacts WHERE client_contacts.client_id = invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), '')
|
||||
))
|
||||
WHEN CHAR_LENGTH((SELECT email FROM client_contacts WHERE client_contacts.client_id = invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1)) > 0
|
||||
THEN (SELECT email FROM client_contacts WHERE client_contacts.client_id = invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1)
|
||||
ELSE 'No Contact Set'
|
||||
END " . $dir
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
if ($sort_col[0] == 'project_id') {
|
||||
|
||||
@@ -338,6 +338,33 @@ abstract class QueryFilters
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Filter by created at date range
|
||||
*
|
||||
* @param string $date_range
|
||||
* @return Builder
|
||||
*/
|
||||
public function created_between(string $date_range = ''): Builder
|
||||
{
|
||||
$parts = explode(",", $date_range);
|
||||
|
||||
if (count($parts) != 2 || !in_array('created_at', \Illuminate\Support\Facades\Schema::getColumnListing($this->builder->getModel()->getTable()))) {
|
||||
return $this->builder;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
$start_date = Carbon::parse($parts[0]);
|
||||
$end_date = Carbon::parse($parts[1]);
|
||||
|
||||
return $this->builder->whereBetween('created_at', [$start_date, $end_date]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->builder;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter by date range
|
||||
*
|
||||
|
||||
@@ -141,9 +141,35 @@ class RecurringInvoiceFilters extends QueryFilters
|
||||
$dir = ($sort_col[1] == 'asc') ? 'asc' : 'desc';
|
||||
|
||||
if ($sort_col[0] == 'client_id') {
|
||||
return $this->builder->orderByRaw('ISNULL(client_id), client_id '. $dir)
|
||||
->orderBy(\App\Models\Client::select('name')
|
||||
->whereColumn('clients.id', 'recurring_invoices.client_id'), $dir);
|
||||
|
||||
|
||||
return $this->builder
|
||||
->orderByRaw("
|
||||
CASE
|
||||
WHEN CHAR_LENGTH((SELECT name FROM clients WHERE clients.id = recurring_invoices.client_id LIMIT 1)) > 1
|
||||
THEN (SELECT name FROM clients WHERE clients.id = recurring_invoices.client_id LIMIT 1)
|
||||
WHEN CHAR_LENGTH(CONCAT(
|
||||
COALESCE((SELECT first_name FROM client_contacts WHERE client_contacts.client_id = recurring_invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), ''),
|
||||
COALESCE((SELECT last_name FROM client_contacts WHERE client_contacts.client_id = recurring_invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), '')
|
||||
)) >= 1
|
||||
THEN TRIM(CONCAT(
|
||||
COALESCE((SELECT first_name FROM client_contacts WHERE client_contacts.client_id = recurring_invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), ''),
|
||||
' ',
|
||||
COALESCE((SELECT last_name FROM client_contacts WHERE client_contacts.client_id = recurring_invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1), '')
|
||||
))
|
||||
WHEN CHAR_LENGTH((SELECT email FROM client_contacts WHERE client_contacts.client_id = recurring_invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1)) > 0
|
||||
THEN (SELECT email FROM client_contacts WHERE client_contacts.client_id = recurring_invoices.client_id AND client_contacts.email IS NOT NULL ORDER BY client_contacts.is_primary DESC, client_contacts.id ASC LIMIT 1)
|
||||
ELSE 'No Contact Set'
|
||||
END " . $dir
|
||||
);
|
||||
|
||||
|
||||
|
||||
// return $this->builder->orderByRaw('client_id IS NULL')
|
||||
// ->orderBy(\App\Models\Client::select('name')
|
||||
// ->whereColumn('clients.id', 'recurring_invoices.client_id')
|
||||
// ->limit(1), $dir);
|
||||
|
||||
}
|
||||
|
||||
if ($sort_col[0] == 'number') {
|
||||
|
||||
@@ -297,10 +297,10 @@ class Nordigen
|
||||
* getTransactions
|
||||
*
|
||||
* @param string $accountId
|
||||
* @param string $dateFrom
|
||||
* @param ?string $dateFrom
|
||||
* @return array
|
||||
*/
|
||||
public function getTransactions(Company $company, string $accountId, string $dateFrom = null): array
|
||||
public function getTransactions(Company $company, string $accountId, ?string $dateFrom = null): array
|
||||
{
|
||||
$transactionResponse = $this->client->account($accountId)->getAccountTransactions($dateFrom);
|
||||
|
||||
@@ -310,7 +310,7 @@ class Nordigen
|
||||
|
||||
public function disabledAccountEmail(BankIntegration $bank_integration): void
|
||||
{
|
||||
$cache_key = "email_quota:{$bank_integration->company->company_key}:{$bank_integration->id}";
|
||||
$cache_key = "email_quota:{$bank_integration->account->key}:bank_integration_notified";
|
||||
|
||||
if (Cache::has($cache_key)) {
|
||||
return;
|
||||
|
||||
@@ -11,37 +11,42 @@
|
||||
|
||||
namespace App\Helpers\Cache;
|
||||
|
||||
use Illuminate\Contracts\Redis\Factory as RedisFactory;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
class Atomic
|
||||
{
|
||||
public static function set($key, $value = true, $ttl = 1): bool
|
||||
public static function set(string $key, mixed $value = true, int $ttl = 1): bool
|
||||
{
|
||||
$new_ttl = now()->addSeconds($ttl);
|
||||
|
||||
try {
|
||||
return Redis::connection('sentinel-cache')->set($key, $value, 'EX', $ttl, 'NX') ? true : false;
|
||||
/** @var RedisFactory $redis */
|
||||
$redis = app('redis');
|
||||
$result = $redis->connection('sentinel-cache')->command('set', [$key, $value, 'EX', $ttl, 'NX']);
|
||||
return (bool) $result;
|
||||
} catch (\Throwable) {
|
||||
return Cache::add($key, $value, $new_ttl) ? true : false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static function get($key)
|
||||
public static function get(string $key): mixed
|
||||
{
|
||||
try {
|
||||
return Redis::connection('sentinel-cache')->get($key);
|
||||
/** @var RedisFactory $redis */
|
||||
$redis = app('redis');
|
||||
return $redis->connection('sentinel-cache')->command('get', [$key]);
|
||||
} catch (\Throwable) {
|
||||
return Cache::get($key);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static function del($key)
|
||||
public static function del(string $key): mixed
|
||||
{
|
||||
try {
|
||||
return Redis::connection('sentinel-cache')->del($key);
|
||||
/** @var RedisFactory $redis */
|
||||
$redis = app('redis');
|
||||
return $redis->connection('sentinel-cache')->command('del', [$key]);
|
||||
} catch (\Throwable) {
|
||||
return Cache::forget($key);
|
||||
}
|
||||
|
||||
@@ -146,6 +146,8 @@ class InvoiceItemSum
|
||||
|
||||
private RuleInterface $rule;
|
||||
|
||||
public bool $peppol_enabled = false;
|
||||
|
||||
public function __construct(RecurringInvoice | Invoice | Quote | Credit | PurchaseOrder | RecurringQuote $invoice)
|
||||
{
|
||||
$this->tax_collection = collect([]);
|
||||
@@ -157,6 +159,7 @@ class InvoiceItemSum
|
||||
if ($this->client) {
|
||||
$this->currency = $this->client->currency();
|
||||
$this->shouldCalculateTax();
|
||||
$this->peppol_enabled = $this->client->getSetting('e_invoice_type') == 'PEPPOL';
|
||||
} else {
|
||||
$this->currency = $this->invoice->vendor->currency();
|
||||
}
|
||||
|
||||
@@ -141,11 +141,14 @@ class InvoiceSum
|
||||
}
|
||||
|
||||
if (is_string($this->invoice->tax_name1) && strlen($this->invoice->tax_name1) >= 2) {
|
||||
|
||||
$tax = $this->taxer($this->total, $this->invoice->tax_rate1);
|
||||
|
||||
$tax += $this->getSurchargeTaxTotalForKey($this->invoice->tax_name1, $this->invoice->tax_rate1);
|
||||
|
||||
$this->total_taxes += $tax;
|
||||
$this->total_tax_map[] = ['name' => $this->invoice->tax_name1.' '.Number::formatValueNoTrailingZeroes(floatval($this->invoice->tax_rate1), $this->client).'%', 'total' => $tax, 'tax_rate' => $this->invoice->tax_rate1];
|
||||
|
||||
}
|
||||
|
||||
if (is_string($this->invoice->tax_name2) && strlen($this->invoice->tax_name2) >= 2) {
|
||||
|
||||
@@ -19,11 +19,22 @@ trait Taxer
|
||||
{
|
||||
public function taxer($amount, $tax_rate)
|
||||
{
|
||||
return round($amount * (($tax_rate ? $tax_rate : 0) / 100), 2);
|
||||
if(!$tax_rate || $tax_rate == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(\App\Utils\BcMath::mul($amount, $tax_rate/100), 2, PHP_ROUND_HALF_UP);
|
||||
// return round(($amount * (($tax_rate ? $tax_rate : 0) / 100)), 2);
|
||||
}
|
||||
|
||||
public function calcAmountLineTax($tax_rate, $amount)
|
||||
{
|
||||
$tax_amount = ($amount * $tax_rate / 100);
|
||||
|
||||
if($this->peppol_enabled) {
|
||||
return $tax_amount;
|
||||
}
|
||||
|
||||
return $this->formatValue(($amount * $tax_rate / 100), 2);
|
||||
}
|
||||
|
||||
|
||||
@@ -68,10 +68,12 @@ class SwissQrGenerator
|
||||
// Add creditor information
|
||||
// Who will receive the payment and to which bank account?
|
||||
$qrBill->setCreditor(
|
||||
QrBill\DataGroup\Element\CombinedAddress::create(
|
||||
$this->company->present()->name(),
|
||||
$this->company->present()->address1(),
|
||||
$this->company->present()->getCompanyCityState(),
|
||||
QrBill\DataGroup\Element\StructuredAddress::createWithStreet(
|
||||
substr($this->company->present()->name(), 0, 70),
|
||||
$this->company->settings->address1 ? substr($this->company->settings->address1, 0, 70) : ' ',
|
||||
$this->company->settings->address2 ? substr($this->company->settings->address2, 0, 16) : ' ',
|
||||
$this->company->settings->postal_code ? substr($this->company->settings->postal_code, 0, 16) : ' ',
|
||||
$this->company->settings->city ? substr($this->company->settings->city, 0, 35) : ' ',
|
||||
'CH'
|
||||
)
|
||||
);
|
||||
@@ -110,11 +112,11 @@ class SwissQrGenerator
|
||||
// Add payment reference
|
||||
// This is what you will need to identify incoming payments.
|
||||
|
||||
if (stripos($this->invoice->number, "Live") === 0) {
|
||||
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 = $this->invoice->number ?? '';
|
||||
$tempInvoiceNumber = preg_replace('/[^A-Za-z0-9]/', '', $tempInvoiceNumber);
|
||||
// $tempInvoiceNumber = substr($tempInvoiceNumber, 1);
|
||||
|
||||
@@ -170,7 +172,7 @@ class SwissQrGenerator
|
||||
$output = new QrBill\PaymentPart\Output\HtmlOutput\HtmlOutput($qrBill, $this->resolveLanguage());
|
||||
|
||||
$html = $output
|
||||
->setPrintable(false)
|
||||
// ->setPrintable(false)
|
||||
->getPaymentPart();
|
||||
|
||||
// return $html;
|
||||
|
||||
@@ -102,7 +102,9 @@ class ActivityController extends BaseController
|
||||
/** @var \App\Models\User auth()->user() */
|
||||
$user = auth()->user();
|
||||
|
||||
if (!$user->isAdmin()) {
|
||||
$entity = $request->getEntity();
|
||||
|
||||
if ($user->cannot('view', $entity)) {
|
||||
$activities->where('user_id', auth()->user()->id);
|
||||
}
|
||||
|
||||
@@ -132,6 +134,11 @@ class ActivityController extends BaseController
|
||||
$backup = $activity->backup;
|
||||
$html_backup = '';
|
||||
|
||||
|
||||
if (!$activity->backup) {
|
||||
return response()->json(['message' => ctrans('texts.no_backup_exists'), 'errors' => new stdClass()], 404);
|
||||
}
|
||||
|
||||
$file = $backup->getFile();
|
||||
|
||||
$html_backup = $file;
|
||||
|
||||
@@ -200,6 +200,40 @@ class LoginController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshReact(Request $request)
|
||||
{
|
||||
$truth = app()->make(TruthSource::class);
|
||||
|
||||
if ($truth->getCompanyToken()) {
|
||||
$company_token = $truth->getCompanyToken();
|
||||
} else {
|
||||
$company_token = CompanyToken::where('token', $request->header('X-API-TOKEN'))->first();
|
||||
}
|
||||
|
||||
$cu = CompanyUser::query()
|
||||
->where('user_id', $company_token->user_id);
|
||||
|
||||
if ($cu->count() == 0) {
|
||||
return response()->json(['message' => 'User found, but not attached to any companies, please see your administrator'], 400);
|
||||
}
|
||||
|
||||
$cu->first()->account->companies->each(function ($company) use ($cu, $request) {
|
||||
if ($company->tokens()->where('is_system', true)->count() == 0) {
|
||||
(new CreateCompanyToken($company, $cu->first()->user, $request->server('HTTP_USER_AGENT')))->handle();
|
||||
}
|
||||
});
|
||||
|
||||
if ($request->has('current_company') && $request->input('current_company') == 'true') {
|
||||
$cu->where('company_id', $company_token->company_id);
|
||||
}
|
||||
|
||||
if (Ninja::isHosted() && !$cu->first()->is_owner && !$cu->first()->user->account->isEnterprisePaidClient()) {
|
||||
return response()->json(['message' => 'Pro / Free accounts only the owner can log in. Please upgrade'], 403);
|
||||
}
|
||||
|
||||
return $this->refreshReactResponse($cu);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the data feed with the current Company User.
|
||||
*
|
||||
|
||||
@@ -78,6 +78,11 @@ class YodleeController extends BaseController
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if ($bi = BankIntegration::where('bank_account_id', $account['id'])->where('company_id', $company->id)->first()) {
|
||||
|
||||
if($bi->deleted_at){
|
||||
continue;
|
||||
}
|
||||
|
||||
$bi->disabled_upstream = false;
|
||||
$bi->balance = $account['current_balance'];
|
||||
$bi->currency = $account['account_currency'];
|
||||
|
||||
@@ -286,7 +286,7 @@ class BankIntegrationController extends BaseController
|
||||
$bank_integration->bank_account_status = "429 Rate limit reached, check back later....";
|
||||
$bank_integration->save();
|
||||
return;
|
||||
} elseif (is_array($account) && isset($account['account_status']) && !in_array($account['account_status'], ['READY', 'PROCESSING','DISCOVERED'])) {
|
||||
} elseif (is_array($account) && isset($account['account_status']) && !in_array($account['account_status'], ['READY', 'PROCESSING', 'DISCOVERED'])) {
|
||||
$bank_integration->disabled_upstream = true;
|
||||
$bank_integration->save();
|
||||
|
||||
|
||||
@@ -297,6 +297,44 @@ class BaseController extends Controller
|
||||
return response()->make($error, $httpErrorCode, $headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heavily reduced refresh query to reduce DB burden
|
||||
*
|
||||
* @param Builder $query
|
||||
* @return Response| \Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function refreshReactResponse($query)
|
||||
{
|
||||
$this->manager->parseIncludes([
|
||||
'account',
|
||||
'user.company_user',
|
||||
'token',
|
||||
'company.tax_rates',
|
||||
]);
|
||||
|
||||
$this->serializer = request()->input('serializer') ?: EntityTransformer::API_SERIALIZER_ARRAY;
|
||||
|
||||
if ($this->serializer === EntityTransformer::API_SERIALIZER_JSON) {
|
||||
$this->manager->setSerializer(new JsonApiSerializer());
|
||||
} else {
|
||||
$this->manager->setSerializer(new ArraySerializer());
|
||||
}
|
||||
|
||||
$transformer = new $this->entity_transformer($this->serializer);
|
||||
|
||||
$limit = $this->resolveQueryLimit();
|
||||
|
||||
$paginator = $query->paginate($limit);
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$query = $paginator->getCollection(); // @phpstan-ignore-line
|
||||
|
||||
$resource = new Collection($query, $transformer, $this->entity_type);
|
||||
|
||||
$resource->setPaginator(new IlluminatePaginatorAdapter($paginator));
|
||||
|
||||
return $this->response($this->manager->createData($resource)->toArray());
|
||||
}
|
||||
/**
|
||||
* Refresh API response with latest cahnges
|
||||
*
|
||||
@@ -681,7 +719,10 @@ class BaseController extends Controller
|
||||
// Set created_at to current time to filter out all existing related records
|
||||
// (designs, documents, groups, etc.) for a minimal response payload
|
||||
request()->merge(['created_at' => time()]);
|
||||
return $this->miniLoadResponse($query);
|
||||
|
||||
//2026-01-23: Improve Login Performance for react.
|
||||
return $this->refreshReactResponse($query);
|
||||
// return $this->miniLoadResponse($query);
|
||||
}
|
||||
elseif ($user->getCompany()->is_large) {
|
||||
$this->manager->parseIncludes($this->mini_load);
|
||||
|
||||
@@ -90,6 +90,10 @@ class QuoteController extends Controller
|
||||
return $this->approve((array) $transformed_ids, $request->has('process'));
|
||||
}
|
||||
|
||||
if ($request->action == 'reject') {
|
||||
return $this->reject((array) $transformed_ids, $request->has('process'));
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
@@ -171,6 +175,44 @@ class QuoteController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
protected function reject(array $ids, $process = false)
|
||||
{
|
||||
$quotes = Quote::query()
|
||||
->whereIn('id', $ids)
|
||||
->where('client_id', auth()->guard('contact')->user()->client_id)
|
||||
->where('company_id', auth()->guard('contact')->user()->company_id)
|
||||
->where('status_id', Quote::STATUS_SENT)
|
||||
->withTrashed()
|
||||
->get();
|
||||
|
||||
if (! $quotes || $quotes->count() == 0) {
|
||||
return redirect()
|
||||
->route('client.quotes.index')
|
||||
->with('message', ctrans('texts.quotes_with_status_sent_can_be_rejected'));
|
||||
}
|
||||
|
||||
if ($process) {
|
||||
foreach ($quotes as $quote) {
|
||||
|
||||
$quote->service()->reject(auth()->guard('contact')->user(), request()->input('user_input', ''))->save();
|
||||
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('client.quotes.index')
|
||||
->withSuccess('Quote(s) rejected successfully.');
|
||||
}
|
||||
|
||||
|
||||
$variables = false;
|
||||
|
||||
return $this->render('quotes.reject', [
|
||||
'quotes' => $quotes,
|
||||
'variables' => $variables,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
protected function approve(array $ids, $process = false)
|
||||
{
|
||||
$quotes = Quote::query()
|
||||
|
||||
@@ -22,8 +22,8 @@ use App\Jobs\Util\ApplePayDomain;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Factory\CompanyGatewayFactory;
|
||||
use App\Filters\CompanyGatewayFilters;
|
||||
use App\Repositories\CompanyRepository;
|
||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||
use App\Repositories\CompanyGatewayRepository;
|
||||
use App\Transformers\CompanyGatewayTransformer;
|
||||
use App\PaymentDrivers\Stripe\Jobs\StripeWebhook;
|
||||
use App\PaymentDrivers\CheckoutCom\CheckoutSetupWebhook;
|
||||
@@ -63,9 +63,9 @@ class CompanyGatewayController extends BaseController
|
||||
|
||||
/**
|
||||
* CompanyGatewayController constructor.
|
||||
* @param CompanyRepository $company_repo
|
||||
* @param CompanyGatewayRepository $company_repo
|
||||
*/
|
||||
public function __construct(CompanyRepository $company_repo)
|
||||
public function __construct(CompanyGatewayRepository $company_repo)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
@@ -210,10 +210,14 @@ class CompanyGatewayController extends BaseController
|
||||
/** @var \App\Models\User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
$company = $user->company();
|
||||
|
||||
$company_gateway = CompanyGatewayFactory::create($user->company()->id, $user->id);
|
||||
$company_gateway->fill($request->all());
|
||||
$company_gateway->save();
|
||||
|
||||
$this->company_repo->addGatewayToCompanyGatewayIds($company_gateway);
|
||||
|
||||
/*Always ensure at least one fees and limits object is set per gateway*/
|
||||
$gateway_types = $company_gateway->driver(new Client())->getAvailableMethods();
|
||||
|
||||
|
||||
@@ -527,11 +527,27 @@ class DesignController extends BaseController
|
||||
|
||||
$ids = request()->input('ids');
|
||||
|
||||
$designs = Design::withTrashed()->company()->whereIn('id', $this->transformKeys($ids));
|
||||
|
||||
/** @var \App\Models\User $user */
|
||||
$user = auth()->user();
|
||||
|
||||
|
||||
if($action == 'clone') {
|
||||
$design = Design::withTrashed()
|
||||
->whereIn('id', $this->transformKeys($ids))
|
||||
->where(function ($q){
|
||||
$q->where('company_id', auth()->user()->company()->id)
|
||||
->orWhereNull('company_id');
|
||||
})->first();
|
||||
|
||||
if($design){
|
||||
$this->design_repo->clone($design, $user);
|
||||
}
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
$designs = Design::withTrashed()->company()->whereIn('id', $this->transformKeys($ids));
|
||||
|
||||
$designs->each(function ($design, $key) use ($action, $user) {
|
||||
if ($user->can('edit', $design)) {
|
||||
$this->design_repo->{$action}($design);
|
||||
|
||||
@@ -42,12 +42,29 @@ class EInvoiceController extends BaseController
|
||||
*/
|
||||
public function validateEntity(ValidateEInvoiceRequest $request)
|
||||
{
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if(!in_array($user->company()->settings->e_invoice_type, ['VERIFACTU', 'PEPPOL'])) {
|
||||
|
||||
$data = [
|
||||
'passes' => true,
|
||||
'invoices' => [],
|
||||
'recurring_invoices' => [],
|
||||
'clients' => [],
|
||||
'companies' => [],
|
||||
];
|
||||
|
||||
return response()->json($data, 200);
|
||||
}
|
||||
|
||||
$el = $request->getValidatorClass();
|
||||
|
||||
$data = [];
|
||||
|
||||
match ($request->entity) {
|
||||
'invoices' => $data = $el->checkInvoice($request->getEntity()),
|
||||
'recurring_invoices' => $data = $el->checkRecurringInvoice($request->getEntity()),
|
||||
'clients' => $data = $el->checkClient($request->getEntity()),
|
||||
'companies' => $data = $el->checkCompany($request->getEntity()),
|
||||
default => $data['passes'] = false,
|
||||
@@ -87,7 +104,24 @@ class EInvoiceController extends BaseController
|
||||
$pm->CardAccount = $card_account;
|
||||
}
|
||||
|
||||
if (isset($payment_means['iban'])) {
|
||||
if (isset($payment_means['code']) && $payment_means['code'] == '58') {
|
||||
$fib = new FinancialInstitutionBranch();
|
||||
$fi = new FinancialInstitution();
|
||||
$bic_id = new ID();
|
||||
$bic_id->value = $payment_means['bic_swift'];
|
||||
$fi->ID = $bic_id;
|
||||
$fib->FinancialInstitution = $fi;
|
||||
$pfa = new PayeeFinancialAccount();
|
||||
$iban_id = new ID();
|
||||
$iban_id->value = $payment_means['iban'];
|
||||
$pfa->ID = $iban_id;
|
||||
$pfa->Name = $payment_means['account_holder'] ?? 'SEPA_CREDIT_TRANSFER';
|
||||
$pfa->FinancialInstitutionBranch = $fib;
|
||||
|
||||
$pm->PayeeFinancialAccount = $pfa;
|
||||
|
||||
}
|
||||
else if (isset($payment_means['iban'])) {
|
||||
$fib = new FinancialInstitutionBranch();
|
||||
$fi = new FinancialInstitution();
|
||||
$bic_id = new ID();
|
||||
|
||||
@@ -52,7 +52,6 @@ class EInvoicePeppolController extends BaseController
|
||||
*/
|
||||
public function setup(StoreEntityRequest $request, Storecove $storecove): Response|JsonResponse
|
||||
{
|
||||
|
||||
/**
|
||||
* @var \App\Models\Company
|
||||
*/
|
||||
|
||||
@@ -446,7 +446,6 @@ class ImportController extends Controller
|
||||
|
||||
$csv = Reader::fromString($csvfile);
|
||||
|
||||
// $csv = Reader::createFromString($csvfile);
|
||||
$csvdelimiter = self::detectDelimiter($csvfile);
|
||||
$csv->setDelimiter($csvdelimiter);
|
||||
$stmt = new Statement();
|
||||
@@ -456,7 +455,7 @@ class ImportController extends Controller
|
||||
$headers = $data[0];
|
||||
|
||||
// Remove Invoice Ninja headers
|
||||
if (count($headers) && count($data) > 4) {
|
||||
if (is_array($headers) && count($headers) > 0 && count($data) > 4) {
|
||||
$firstCell = $headers[0];
|
||||
|
||||
if (strstr($firstCell, (string) config('ninja.app_name'))) {
|
||||
|
||||
@@ -20,39 +20,72 @@ use App\Services\Quickbooks\QuickbooksService;
|
||||
|
||||
class ImportQuickbooksController extends BaseController
|
||||
{
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
* authorizeQuickbooks
|
||||
*
|
||||
* Starts the Quickbooks authorization process.
|
||||
*
|
||||
* @param AuthQuickbooksRequest $request
|
||||
* @param string $token
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function authorizeQuickbooks(AuthQuickbooksRequest $request, string $token)
|
||||
{
|
||||
|
||||
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
|
||||
|
||||
$company = $request->getCompany();
|
||||
|
||||
$qb = new QuickbooksService($company);
|
||||
|
||||
$authorizationUrl = $qb->sdk()->getAuthorizationUrl();
|
||||
|
||||
nlog($authorizationUrl);
|
||||
|
||||
$state = $qb->sdk()->getState();
|
||||
|
||||
Cache::put($state, $token, 190);
|
||||
|
||||
return redirect()->to($authorizationUrl);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* onAuthorized
|
||||
*
|
||||
* Handles the callback from Quickbooks after authorization.
|
||||
*
|
||||
* @param AuthorizedQuickbooksRequest $request
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function onAuthorized(AuthorizedQuickbooksRequest $request)
|
||||
{
|
||||
|
||||
nlog($request->all());
|
||||
|
||||
MultiDB::findAndSetDbByCompanyKey($request->getTokenContent()['company_key']);
|
||||
$company = $request->getCompany();
|
||||
|
||||
$qb = new QuickbooksService($company);
|
||||
|
||||
$realm = $request->query('realmId');
|
||||
|
||||
nlog($realm);
|
||||
|
||||
$access_token_object = $qb->sdk()->accessTokenFromCode($request->query('code'), $realm);
|
||||
|
||||
nlog($access_token_object);
|
||||
|
||||
$qb->sdk()->saveOAuthToken($access_token_object);
|
||||
|
||||
// Refresh the service to initialize SDK with the new access token
|
||||
$qb->refresh();
|
||||
|
||||
$companyInfo = $qb->sdk()->company();
|
||||
|
||||
$company->quickbooks->companyName = $companyInfo->CompanyName;
|
||||
$company->save();
|
||||
|
||||
nlog($companyInfo);
|
||||
|
||||
return redirect(config('ninja.react_url'));
|
||||
|
||||
}
|
||||
|
||||
@@ -607,7 +607,7 @@ class ProjectController extends BaseController
|
||||
$this->entity_transformer = InvoiceTransformer::class;
|
||||
$this->entity_type = Invoice::class;
|
||||
|
||||
$invoice = $this->project_repo->invoice($project);
|
||||
$invoice = $this->project_repo->invoice(collect([$project]));
|
||||
|
||||
return $this->itemResponse($invoice);
|
||||
}
|
||||
|
||||
66
app/Http/Controllers/QuickbooksController.php
Normal file
66
app/Http/Controllers/QuickbooksController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Libraries\MultiDB;
|
||||
use Illuminate\Http\Response;
|
||||
use App\Services\Quickbooks\QuickbooksService;
|
||||
use App\Http\Requests\Quickbooks\SyncQuickbooksRequest;
|
||||
use App\Http\Requests\Quickbooks\ConfigQuickbooksRequest;
|
||||
use App\Http\Requests\Quickbooks\DisconnectQuickbooksRequest;
|
||||
|
||||
class QuickbooksController extends BaseController
|
||||
{
|
||||
|
||||
public function sync(SyncQuickbooksRequest $request)
|
||||
{
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
public function configuration(ConfigQuickbooksRequest $request)
|
||||
{
|
||||
|
||||
$user = auth()->user();
|
||||
$company = $user->company();
|
||||
|
||||
$quickbooks = $company->quickbooks;
|
||||
$quickbooks->settings->client->direction = $request->clients ? SyncDirection::PUSH : SyncDirection::NONE;
|
||||
$quickbooks->settings->vendor->direction = $request->vendors ? SyncDirection::PUSH : SyncDirection::NONE;
|
||||
$quickbooks->settings->product->direction = $request->products ? SyncDirection::PUSH : SyncDirection::NONE;
|
||||
$quickbooks->settings->invoice->direction = $request->invoices ? SyncDirection::PUSH : SyncDirection::NONE;
|
||||
$quickbooks->settings->quote->direction = $request->quotes ? SyncDirection::PUSH : SyncDirection::NONE;
|
||||
$quickbooks->settings->payment->direction = $request->payments ? SyncDirection::PUSH : SyncDirection::NONE;
|
||||
$company->quickbooks = $quickbooks;
|
||||
$company->save();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
public function disconnect(DisconnectQuickbooksRequest $request)
|
||||
{
|
||||
|
||||
$user = auth()->user();
|
||||
$company = $user->company();
|
||||
|
||||
$qb = new QuickbooksService($company);
|
||||
$rs = $qb->sdk()->revokeAccessToken();
|
||||
|
||||
nlog($rs);
|
||||
|
||||
$company->quickbooks = null;
|
||||
$company->save();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -232,7 +232,7 @@ class SearchController extends Controller
|
||||
'name' => $result['_source']['name'],
|
||||
'type' => '/expense',
|
||||
'id' => $result['_source']['hashed_id'],
|
||||
'path' => "/expenses/{$result['_source']['hashed_id']}"
|
||||
'path' => "/expenses/{$result['_source']['hashed_id']}/edit"
|
||||
];
|
||||
|
||||
break;
|
||||
|
||||
@@ -185,7 +185,7 @@ class SetupController extends Controller
|
||||
try {
|
||||
$status = SystemHealth::dbCheck($request);
|
||||
|
||||
if (is_array($status) && $status['success'] === true) {
|
||||
if (is_array($status) && $status['success'] === true && \App\Models\Account::count() > 0) {
|
||||
return response([], 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,31 +12,32 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Utils\Ninja;
|
||||
use App\Models\CompanyUser;
|
||||
use App\Factory\UserFactory;
|
||||
use App\Filters\UserFilters;
|
||||
use Illuminate\Http\Response;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use App\Events\User\UserWasCreated;
|
||||
use App\Events\User\UserWasDeleted;
|
||||
use App\Events\User\UserWasUpdated;
|
||||
use App\Factory\UserFactory;
|
||||
use App\Filters\UserFilters;
|
||||
use App\Http\Controllers\Traits\VerifiesUserEmail;
|
||||
use App\Http\Requests\User\BulkUserRequest;
|
||||
use App\Http\Requests\User\CreateUserRequest;
|
||||
use App\Http\Requests\User\DestroyUserRequest;
|
||||
use App\Http\Requests\User\DetachCompanyUserRequest;
|
||||
use App\Http\Requests\User\DisconnectUserMailerRequest;
|
||||
use App\Http\Requests\User\EditUserRequest;
|
||||
use App\Http\Requests\User\ReconfirmUserRequest;
|
||||
use App\Http\Requests\User\ShowUserRequest;
|
||||
use App\Http\Requests\User\StoreUserRequest;
|
||||
use App\Http\Requests\User\UpdateUserRequest;
|
||||
use App\Jobs\Company\CreateCompanyToken;
|
||||
use App\Jobs\User\UserEmailChanged;
|
||||
use App\Models\CompanyUser;
|
||||
use App\Models\User;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Transformers\UserTransformer;
|
||||
use App\Utils\Ninja;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use Illuminate\Http\Response;
|
||||
use App\Jobs\Company\CreateCompanyToken;
|
||||
use App\Http\Requests\User\BulkUserRequest;
|
||||
use App\Http\Requests\User\EditUserRequest;
|
||||
use App\Http\Requests\User\ShowUserRequest;
|
||||
use App\Http\Requests\User\PurgeUserRequest;
|
||||
use App\Http\Requests\User\StoreUserRequest;
|
||||
use App\Http\Requests\User\CreateUserRequest;
|
||||
use App\Http\Requests\User\UpdateUserRequest;
|
||||
use App\Http\Requests\User\DestroyUserRequest;
|
||||
use App\Http\Requests\User\ReconfirmUserRequest;
|
||||
use App\Http\Controllers\Traits\VerifiesUserEmail;
|
||||
use App\Http\Requests\User\DetachCompanyUserRequest;
|
||||
use App\Http\Requests\User\DisconnectUserMailerRequest;
|
||||
|
||||
/**
|
||||
* Class UserController.
|
||||
@@ -350,4 +351,12 @@ class UserController extends BaseController
|
||||
|
||||
}
|
||||
|
||||
public function purge(PurgeUserRequest $request, User $user)
|
||||
{
|
||||
$this->user_repo->purge($user, auth()->user());
|
||||
|
||||
return response()->noContent();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -303,7 +303,6 @@ class WebhookController extends BaseController
|
||||
|
||||
$webhook = WebhookFactory::create($user->company()->id, $user->id);
|
||||
$webhook->fill($request->all());
|
||||
$webhook->save();
|
||||
|
||||
return $this->itemResponse($webhook);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
namespace App\Http\Requests\Activity;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use App\Http\Requests\Request;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
|
||||
@@ -48,4 +49,16 @@ class ShowActivityRequest extends Request
|
||||
$this->replace($input);
|
||||
|
||||
}
|
||||
|
||||
public function getEntity()
|
||||
{
|
||||
if (!$this->entity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$class = "\\App\\Models\\".ucfirst(Str::camel(rtrim($this->entity, 's')));
|
||||
return $class::withTrashed()->company()->where('id', is_string($this->entity_id) ? $this->decodePrimaryKey($this->entity_id) : $this->entity_id)->first();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreNoteRequest extends Request
|
||||
{
|
||||
public $error_message;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
|
||||
@@ -50,6 +50,8 @@ class ShowChartRequest extends Request
|
||||
|
||||
$input = $this->all();
|
||||
|
||||
$input['include_drafts'] = filter_var($input['include_drafts'] ?? false, FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (isset($input['date_range'])) {
|
||||
$dates = $this->calculateStartAndEndDates($input, $user->company());
|
||||
$input['start_date'] = $dates[0];
|
||||
|
||||
@@ -15,8 +15,6 @@ namespace App\Http\Requests\ClientPortal\Quotes;
|
||||
use App\Http\ViewComposers\PortalComposer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
use function auth;
|
||||
|
||||
class ProcessQuotesInBulkRequest extends FormRequest
|
||||
{
|
||||
public function authorize()
|
||||
|
||||
@@ -130,15 +130,26 @@ class UpdateCompanyRequest extends Request
|
||||
$input['portal_domain'] = rtrim(strtolower($input['portal_domain']), "/");
|
||||
}
|
||||
|
||||
// /** Disabled on the hosted platform */
|
||||
// if (isset($input['expense_mailbox']) && Ninja::isHosted() && !($this->company->account->isPaid() && $this->company->account->plan == 'enterprise')) {
|
||||
// unset($input['expense_mailbox']);
|
||||
// }
|
||||
|
||||
|
||||
if (isset($input['settings'])) {
|
||||
$input['settings'] = (array) $this->filterSaveableSettings($input['settings']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @var \App\Models\User $user
|
||||
*/
|
||||
$user = auth()->user();
|
||||
|
||||
/**
|
||||
* @var \App\Models\Company $company
|
||||
*/
|
||||
$company = $user->company();
|
||||
|
||||
if(isset($company->legal_entity_id) && intval($company->legal_entity_id) > 0){
|
||||
$input['settings']['e_invoice_type'] = 'PEPPOL';
|
||||
}
|
||||
|
||||
if (isset($input['subdomain']) && $this->company->subdomain == $input['subdomain']) {
|
||||
unset($input['subdomain']);
|
||||
}
|
||||
@@ -183,6 +194,10 @@ class UpdateCompanyRequest extends Request
|
||||
$input['session_timeout'] = 0;
|
||||
}
|
||||
|
||||
if($company->settings->e_invoice_type == 'VERIFACTU') {
|
||||
$input['calculate_taxes'] = false;
|
||||
}
|
||||
|
||||
$this->replace($input);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
namespace App\Http\Requests\Credit;
|
||||
|
||||
use App\Http\Requests\Request;
|
||||
use App\Utils\Traits\ChecksEntityStatus;
|
||||
use App\Utils\Traits\CleanLineItems;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Utils\Traits\CleanLineItems;
|
||||
use App\Utils\Traits\ChecksEntityStatus;
|
||||
use App\Http\ValidationRules\EInvoice\ValidCreditScheme;
|
||||
|
||||
class UpdateCreditRequest extends Request
|
||||
{
|
||||
@@ -81,6 +82,8 @@ class UpdateCreditRequest extends Request
|
||||
|
||||
$rules['location_id'] = ['nullable', 'sometimes','bail', Rule::exists('locations', 'id')->where('company_id', $user->company()->id)->where('client_id', $this->credit->client_id)];
|
||||
|
||||
$rules['e_invoice'] = ['sometimes', 'nullable', new ValidCreditScheme()];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
@@ -94,6 +97,7 @@ class UpdateCreditRequest extends Request
|
||||
{
|
||||
$input = $this->all();
|
||||
|
||||
nlog($input);
|
||||
$input = $this->decodePrimaryKeys($input);
|
||||
|
||||
if (isset($input['documents'])) {
|
||||
|
||||
@@ -39,7 +39,7 @@ class UpdateEInvoiceConfiguration extends Request
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'entity' => 'required|bail|in:invoice,client,company',
|
||||
'entity' => 'required|bail|in:credit,invoice,client,company',
|
||||
'payment_means' => 'sometimes|bail|array',
|
||||
'payment_means.*.code' => ['required_with:payment_means', 'bail', Rule::in(PaymentMeans::getPaymentMeansCodelist())],
|
||||
'payment_means.*.bic_swift' => Rule::forEach(function (string|null $value, string $attribute) {
|
||||
|
||||
@@ -17,8 +17,9 @@ use App\Models\Client;
|
||||
use App\Models\Company;
|
||||
use App\Models\Invoice;
|
||||
use App\Http\Requests\Request;
|
||||
use App\Services\EDocument\Standards\Validation\Peppol\EntityLevel;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Models\RecurringInvoice;
|
||||
use App\Services\EDocument\Standards\Validation\Peppol\EntityLevel;
|
||||
|
||||
class ValidateEInvoiceRequest extends Request
|
||||
{
|
||||
@@ -50,7 +51,7 @@ class ValidateEInvoiceRequest extends Request
|
||||
$user = auth()->user();
|
||||
|
||||
return [
|
||||
'entity' => 'required|bail|in:invoices,clients,companies',
|
||||
'entity' => 'required|bail|in:invoices,recurring_invoices,clients,companies',
|
||||
'entity_id' => ['required','bail', Rule::exists($this->entity, 'id')
|
||||
->when($this->entity != 'companies', function ($q) use ($user) {
|
||||
$q->where('company_id', $user->company()->id);
|
||||
@@ -81,6 +82,7 @@ class ValidateEInvoiceRequest extends Request
|
||||
|
||||
match ($this->entity) {
|
||||
'invoices' => $class = Invoice::class,
|
||||
'recurring_invoices' => $class = RecurringInvoice::class,
|
||||
'clients' => $class = Client::class,
|
||||
'companies' => $class = Company::class,
|
||||
default => $class = Invoice::class,
|
||||
|
||||
@@ -85,6 +85,10 @@ class StoreExpenseRequest extends Request
|
||||
$this->files->set('file', [$this->file('file')]);
|
||||
}
|
||||
|
||||
if(!array_key_exists('amount', $input)){
|
||||
$input['amount'] = 0;
|
||||
}
|
||||
|
||||
if (! array_key_exists('currency_id', $input) || strlen($input['currency_id']) == 0) {
|
||||
$input['currency_id'] = (string) $user->company()->settings->currency_id;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ use App\Utils\Traits\MakesHash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use App\Utils\Traits\CleanLineItems;
|
||||
use App\Utils\Traits\ChecksEntityStatus;
|
||||
use App\Http\ValidationRules\Invoice\LockedInvoiceRule;
|
||||
use App\Http\ValidationRules\EInvoice\ValidInvoiceScheme;
|
||||
use App\Http\ValidationRules\Project\ValidProjectForClient;
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ class PreviewInvoiceRequest extends Request
|
||||
};
|
||||
|
||||
if ($invitation) {
|
||||
nlog($invitation->toArray());
|
||||
// nlog($invitation->toArray());
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
|
||||
49
app/Http/Requests/Quickbooks/ConfigQuickbooksRequest.php
Normal file
49
app/Http/Requests/Quickbooks/ConfigQuickbooksRequest.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Quickbooks;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ConfigQuickbooksRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->user()->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'invoices' => 'required|boolean|bail',
|
||||
'quotes' => 'required|boolean|bail',
|
||||
'payments' => 'required|boolean|bail',
|
||||
'products' => 'required|boolean|bail',
|
||||
'vendors' => 'required|boolean|bail',
|
||||
'clients' => 'required|boolean|bail',
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
44
app/Http/Requests/Quickbooks/DisconnectQuickbooksRequest.php
Normal file
44
app/Http/Requests/Quickbooks/DisconnectQuickbooksRequest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Quickbooks;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class DisconnectQuickbooksRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->user()->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
49
app/Http/Requests/Quickbooks/SyncQuickbooksRequest.php
Normal file
49
app/Http/Requests/Quickbooks/SyncQuickbooksRequest.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\Quickbooks;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SyncQuickbooksRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->user()->isAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'clients' => 'required_with:invoices,quotes,payments|in:email,name,always_create',
|
||||
'products' => 'sometimes|in:product_key,always_create',
|
||||
'invoices' => 'sometimes|in:number,always_create',
|
||||
'quotes' => 'sometimes|in:number,always_create',
|
||||
'payments' => 'sometimes|in:always_create',
|
||||
'vendors' => 'sometimes|in:email,name,always_create',
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
namespace App\Http\Requests\Setup;
|
||||
|
||||
use App\Http\Requests\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CheckDatabaseRequest extends Request
|
||||
{
|
||||
@@ -23,7 +24,16 @@ class CheckDatabaseRequest extends Request
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return true; /* Return something that will check if setup has been completed, like Ninja::hasCompletedSetup() */
|
||||
if (!\App\Utils\Ninja::isSelfHost()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return !Schema::hasTable('accounts') || \App\Models\Account::count() == 0;
|
||||
} catch (\Throwable $e) {
|
||||
// If database connection fails, allow the request (we're checking the DB)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
namespace App\Http\Requests\Setup;
|
||||
|
||||
use App\Http\Requests\Request;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class CheckMailRequest extends Request
|
||||
{
|
||||
@@ -23,7 +24,16 @@ class CheckMailRequest extends Request
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return true; /* Return something that will check if setup has been completed, like Ninja::hasCompletedSetup() */
|
||||
if (!\App\Utils\Ninja::isSelfHost()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return !Schema::hasTable('accounts') || \App\Models\Account::count() == 0;
|
||||
} catch (\Throwable $e) {
|
||||
// If database connection fails, allow the request (we're checking the DB)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -60,7 +60,7 @@ class StoreSubscriptionRequest extends Request
|
||||
'refund_period' => 'bail|sometimes|numeric',
|
||||
'webhook_configuration' => 'bail|array',
|
||||
'webhook_configuration.post_purchase_url' => 'bail|sometimes|nullable|string',
|
||||
'webhook_configuration.post_purchase_rest_method' => 'bail|sometimes|nullable|string',
|
||||
'webhook_configuration.post_purchase_rest_method' => 'bail|sometimes|nullable|in:post,put',
|
||||
'webhook_configuration.post_purchase_headers' => 'bail|sometimes|array',
|
||||
'registration_required' => 'bail|sometimes|bool',
|
||||
'optional_recurring_product_ids' => 'bail|sometimes|nullable|string',
|
||||
@@ -72,6 +72,68 @@ class StoreSubscriptionRequest extends Request
|
||||
return $this->globalRules($rules);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Validation\Validator $validator
|
||||
* @return void
|
||||
*/
|
||||
public function withValidator(\Illuminate\Validation\Validator $validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$this->validateWebhookUrl($validator, 'webhook_configuration.post_purchase_url');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a URL doesn't point to internal/private IP addresses.
|
||||
*
|
||||
* @param \Illuminate\Validation\Validator $validator
|
||||
* @param string $field
|
||||
* @return void
|
||||
*/
|
||||
private function validateWebhookUrl(\Illuminate\Validation\Validator $validator, string $field): void
|
||||
{
|
||||
$url = $this->input($field);
|
||||
|
||||
if (empty($url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed = parse_url($url);
|
||||
|
||||
// Only allow http/https protocols
|
||||
$scheme = $parsed['scheme'] ?? '';
|
||||
if (!in_array(strtolower($scheme), ['http', 'https'])) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
$host = $parsed['host'] ?? '';
|
||||
if (empty($host)) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve hostname to IP and check for private/reserved ranges
|
||||
$ip = gethostbyname($host);
|
||||
|
||||
// gethostbyname returns the hostname if resolution fails
|
||||
if ($ip === $host && !filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
// DNS resolution failed - allow it (external DNS might resolve differently)
|
||||
return;
|
||||
}
|
||||
|
||||
// Block private and reserved IP ranges (SSRF protection)
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
}
|
||||
}
|
||||
|
||||
public function prepareForValidation()
|
||||
{
|
||||
$input = $this->all();
|
||||
|
||||
@@ -61,7 +61,7 @@ class UpdateSubscriptionRequest extends Request
|
||||
'refund_period' => 'bail|sometimes|numeric',
|
||||
'webhook_configuration' => 'bail|array',
|
||||
'webhook_configuration.post_purchase_url' => 'bail|sometimes|nullable|string',
|
||||
'webhook_configuration.post_purchase_rest_method' => 'bail|sometimes|nullable|string',
|
||||
'webhook_configuration.post_purchase_rest_method' => 'bail|sometimes|nullable|in:post,put',
|
||||
'webhook_configuration.post_purchase_headers' => 'bail|sometimes|array',
|
||||
'registration_required' => 'bail|sometimes|bool',
|
||||
'optional_recurring_product_ids' => 'bail|sometimes|nullable|string',
|
||||
@@ -73,6 +73,69 @@ class UpdateSubscriptionRequest extends Request
|
||||
return $this->globalRules($rules);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param \Illuminate\Validation\Validator $validator
|
||||
* @return void
|
||||
*/
|
||||
public function withValidator(\Illuminate\Validation\Validator $validator): void
|
||||
{
|
||||
$validator->after(function ($validator) {
|
||||
$this->validateWebhookUrl($validator, 'webhook_configuration.post_purchase_url');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a URL doesn't point to internal/private IP addresses.
|
||||
*
|
||||
* @param \Illuminate\Validation\Validator $validator
|
||||
* @param string $field
|
||||
* @return void
|
||||
*/
|
||||
private function validateWebhookUrl(\Illuminate\Validation\Validator $validator, string $field): void
|
||||
{
|
||||
$url = $this->input($field);
|
||||
|
||||
if (empty($url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
$parsed = parse_url($url);
|
||||
|
||||
// Only allow http/https protocols
|
||||
$scheme = $parsed['scheme'] ?? '';
|
||||
if (!in_array(strtolower($scheme), ['http', 'https'])) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
$host = $parsed['host'] ?? '';
|
||||
if (empty($host)) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve hostname to IP and check for private/reserved ranges
|
||||
$ip = gethostbyname($host);
|
||||
|
||||
// gethostbyname returns the hostname if resolution fails
|
||||
if ($ip === $host && !filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
// DNS resolution failed - allow it (external DNS might resolve differently)
|
||||
return;
|
||||
}
|
||||
|
||||
// Block private and reserved IP ranges (SSRF protection)
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
||||
$validator->errors()->add($field, ctrans('texts.invalid_url'));
|
||||
}
|
||||
}
|
||||
|
||||
public function prepareForValidation()
|
||||
{
|
||||
$input = $this->all();
|
||||
|
||||
@@ -161,6 +161,10 @@ class StoreTaskRequest extends Request
|
||||
|
||||
}
|
||||
|
||||
if(isset($input['description']) && is_string($input['description'])) {
|
||||
$input['description'] = str_ireplace(['</sc', 'file:/', 'iframe', '<embed', '<embed', '<object', '<object', '127.0.0.1', 'localhost', '<?xml encoding="UTF-8">', '/etc/'], "", $input['description']);
|
||||
}
|
||||
|
||||
/* Ensure the project is related */
|
||||
if (array_key_exists('project_id', $input) && isset($input['project_id'])) {
|
||||
$project = Project::withTrashed()->where('id', $input['project_id'])->company()->first();
|
||||
|
||||
@@ -136,6 +136,10 @@ class UpdateTaskRequest extends Request
|
||||
$input['status_id'] = $this->decodePrimaryKey($input['status_id']);
|
||||
}
|
||||
|
||||
if(isset($input['description']) && is_string($input['description'])) {
|
||||
$input['description'] = str_ireplace(['</sc', 'file:/', 'iframe', '<embed', '<embed', '<object', '<object', '127.0.0.1', 'localhost', '<?xml encoding="UTF-8">', '/etc/'], "", $input['description']);
|
||||
}
|
||||
|
||||
if (isset($input['documents'])) {
|
||||
unset($input['documents']);
|
||||
}
|
||||
|
||||
28
app/Http/Requests/User/PurgeUserRequest.php
Normal file
28
app/Http/Requests/User/PurgeUserRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Requests\User;
|
||||
|
||||
use App\Http\Requests\Request;
|
||||
|
||||
class PurgeUserRequest extends Request
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->user()->isOwner() && auth()->user()->id !== $this->user->id;
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,8 @@ class UpdateUserRequest extends Request
|
||||
$input['email'] = trim($input['email']);
|
||||
} elseif (isset($input['email'])) {
|
||||
$input['email'] = false;
|
||||
} else {
|
||||
$input['email'] = $this->user->email;
|
||||
}
|
||||
|
||||
if (array_key_exists('first_name', $input)) {
|
||||
|
||||
@@ -22,6 +22,13 @@ class BlackListRule implements ValidationRule
|
||||
{
|
||||
/** Bad domains +/- disposable email domains */
|
||||
private array $blacklist = [
|
||||
"usdtbeta.com",
|
||||
"asurad.com",
|
||||
"isb.nu.edu.pk",
|
||||
"edux3.us",
|
||||
"bwmyga.com",
|
||||
"asurad.com",
|
||||
"comfythings.com",
|
||||
"edu.pk",
|
||||
"bablace.com",
|
||||
"moonfee.com",
|
||||
|
||||
106
app/Http/ValidationRules/EInvoice/ValidCreditScheme.php
Normal file
106
app/Http/ValidationRules/EInvoice/ValidCreditScheme.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*1`
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\ValidationRules\EInvoice;
|
||||
|
||||
use App\Services\EDocument\Standards\Validation\Peppol\CreditLevel;
|
||||
use Closure;
|
||||
use InvoiceNinja\EInvoice\EInvoice;
|
||||
use Illuminate\Validation\Validator;
|
||||
use InvoiceNinja\EInvoice\Models\Peppol\Invoice;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Contracts\Validation\ValidatorAwareRule;
|
||||
|
||||
/**
|
||||
* Class ValidScheme.
|
||||
*/
|
||||
class ValidCreditScheme implements ValidationRule, ValidatorAwareRule
|
||||
{
|
||||
/**
|
||||
* The validator instance.
|
||||
*
|
||||
* @var Validator
|
||||
*/
|
||||
protected $validator;
|
||||
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
|
||||
|
||||
if (isset($value['CreditNote'])) {
|
||||
|
||||
$r = new EInvoice();
|
||||
|
||||
if (data_get($value, 'CreditNote.BillingReference.0.InvoiceDocumentReference.IssueDate') === null ||
|
||||
data_get($value, 'CreditNote.BillingReference.0.InvoiceDocumentReference.IssueDate') === '') {
|
||||
unset($value['CreditNote']['BillingReference'][0]['InvoiceDocumentReference']['IssueDate']);
|
||||
}
|
||||
|
||||
$errors = $r->validateRequest($value['CreditNote'], CreditLevel::class);
|
||||
|
||||
foreach ($errors as $key => $msg) {
|
||||
|
||||
$this->validator->errors()->add(
|
||||
"e_invoice.{$key}",
|
||||
"{$key} - {$msg}"
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
if (data_get($value, 'CreditNote.BillingReference.0.InvoiceDocumentReference.ID') === null ||
|
||||
data_get($value, 'CreditNote.BillingReference.0.InvoiceDocumentReference.ID') === '') {
|
||||
|
||||
$this->validator->errors()->add(
|
||||
"e_invoice.BillingReference.0.InvoiceDocumentReference.ID",
|
||||
"Invoice Reference/Number is required"
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
if (isset($value['CreditNote']['BillingReference'][0]['InvoiceDocumentReference']['IssueDate']) && strlen($value['CreditNote']['BillingReference'][0]['InvoiceDocumentReference']['IssueDate']) > 1 && !$this->isValidDateSyntax($value['CreditNote']['BillingReference'][0]['InvoiceDocumentReference']['IssueDate'])) {
|
||||
|
||||
$this->validator->errors()->add(
|
||||
"e_invoice.BillingReference.0.InvoiceDocumentReference.IssueDate",
|
||||
"Invoice Issue Date is required"
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function isValidDateSyntax(string $date_string): bool
|
||||
{
|
||||
// Strict format validation: must be exactly Y-m-d
|
||||
$date = \DateTime::createFromFormat('Y-m-d', $date_string);
|
||||
|
||||
if ($date === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure the formatted date matches the input (catches overflow)
|
||||
return $date->format('Y-m-d') === $date_string;
|
||||
}
|
||||
/**
|
||||
* Set the current validator.
|
||||
*/
|
||||
public function setValidator(Validator $validator): static
|
||||
{
|
||||
$this->validator = $validator;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -88,6 +88,9 @@ class ValidInvoicesRules implements Rule
|
||||
} elseif ($inv->status_id == Invoice::STATUS_DRAFT && floatval($invoice['amount']) > floatval($inv->amount)) {
|
||||
$this->error_msg = 'Amount cannot be greater than invoice balance';
|
||||
return false;
|
||||
} elseif($invoice['amount'] < 0 && $inv->amount >= 0) {
|
||||
$this->error_msg = 'Amount cannot be negative';
|
||||
return false;
|
||||
} elseif (floatval($invoice['amount']) > floatval($inv->balance)) {
|
||||
$this->error_msg = ctrans('texts.amount_greater_than_balance_v5');
|
||||
return false;
|
||||
|
||||
@@ -12,33 +12,35 @@
|
||||
|
||||
namespace App\Import\Providers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Utils\Ninja;
|
||||
use App\Models\Quote;
|
||||
use League\Csv\Reader;
|
||||
use App\Models\Company;
|
||||
use App\Models\Invoice;
|
||||
use League\Csv\Statement;
|
||||
use App\Factory\TaskFactory;
|
||||
use App\Factory\QuoteFactory;
|
||||
use App\Factory\ClientFactory;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Factory\InvoiceFactory;
|
||||
use App\Factory\PaymentFactory;
|
||||
use App\Factory\QuoteFactory;
|
||||
use App\Factory\RecurringInvoiceFactory;
|
||||
use App\Factory\TaskFactory;
|
||||
use App\Http\Requests\Quote\StoreQuoteRequest;
|
||||
use App\Import\ImportException;
|
||||
use App\Jobs\Mail\NinjaMailerJob;
|
||||
use App\Jobs\Mail\NinjaMailerObject;
|
||||
use App\Mail\Import\CsvImportCompleted;
|
||||
use App\Models\Company;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Quote;
|
||||
use App\Models\User;
|
||||
use App\Repositories\ClientRepository;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use App\Repositories\PaymentRepository;
|
||||
use App\Repositories\QuoteRepository;
|
||||
use App\Repositories\RecurringInvoiceRepository;
|
||||
use App\Repositories\TaskRepository;
|
||||
use App\Utils\Traits\CleanLineItems;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Repositories\QuoteRepository;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Repositories\ClientRepository;
|
||||
use App\Mail\Import\CsvImportCompleted;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use App\Repositories\PaymentRepository;
|
||||
use App\Factory\RecurringInvoiceFactory;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use League\Csv\Reader;
|
||||
use League\Csv\Statement;
|
||||
use App\Http\Requests\Quote\StoreQuoteRequest;
|
||||
use App\Repositories\RecurringInvoiceRepository;
|
||||
use App\Notifications\Ninja\GenericNinjaAdminNotification;
|
||||
|
||||
class BaseImport
|
||||
{
|
||||
@@ -70,6 +72,8 @@ class BaseImport
|
||||
|
||||
public array $entity_count = [];
|
||||
|
||||
public bool $store_import_for_research = false;
|
||||
|
||||
public function __construct(array $request, Company $company)
|
||||
{
|
||||
$this->company = $company;
|
||||
@@ -107,7 +111,7 @@ class BaseImport
|
||||
$csv = base64_decode($base64_encoded_csv);
|
||||
// $csv = mb_convert_encoding($csv, 'UTF-8', 'UTF-8');
|
||||
|
||||
$csv = Reader::createFromString($csv);
|
||||
$csv = Reader::fromString($csv);
|
||||
$csvdelimiter = self::detectDelimiter($csv);
|
||||
|
||||
$csv->setDelimiter($csvdelimiter);
|
||||
@@ -119,7 +123,8 @@ class BaseImport
|
||||
|
||||
// Remove Invoice Ninja headers
|
||||
if (
|
||||
count($headers) &&
|
||||
is_array($headers) &&
|
||||
count($headers) > 0 &&
|
||||
count($data) > 4 &&
|
||||
$this->import_type === 'csv'
|
||||
) {
|
||||
@@ -320,7 +325,8 @@ class BaseImport
|
||||
$entity->saveQuietly();
|
||||
$count++;
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
}
|
||||
catch (\Exception $ex) {
|
||||
if (\DB::connection(config('database.default'))->transactionLevel() > 0) {
|
||||
\DB::connection(config('database.default'))->rollBack();
|
||||
}
|
||||
@@ -339,6 +345,20 @@ class BaseImport
|
||||
|
||||
nlog("Ingest {$ex->getMessage()}");
|
||||
nlog($record);
|
||||
|
||||
$this->store_import_for_research = true;
|
||||
|
||||
}
|
||||
catch(\Throwable $ex){
|
||||
if (\DB::connection(config('database.default'))->transactionLevel() > 0) {
|
||||
\DB::connection(config('database.default'))->rollBack();
|
||||
}
|
||||
|
||||
nlog("Throwable:: Ingest {$ex->getMessage()}");
|
||||
nlog($record);
|
||||
|
||||
$this->store_import_for_research = true;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,6 +965,39 @@ class BaseImport
|
||||
$nmo->to_user = $this->company->owner();
|
||||
|
||||
NinjaMailerJob::dispatch($nmo, true);
|
||||
|
||||
/** Debug for import failures */
|
||||
if (Ninja::isHosted() && $this->store_import_for_research) {
|
||||
|
||||
$content = [
|
||||
'company_key - '. $this->company->company_key,
|
||||
'class_name - ' . class_basename($this),
|
||||
'hash - ' => $this->hash,
|
||||
];
|
||||
|
||||
$potential_imports = [
|
||||
'client',
|
||||
'product',
|
||||
'invoice',
|
||||
'payment',
|
||||
'vendor',
|
||||
'expense',
|
||||
'quote',
|
||||
'bank_transaction',
|
||||
'task',
|
||||
'recurring_invoice',
|
||||
];
|
||||
|
||||
foreach ($potential_imports as $import) {
|
||||
|
||||
if(Cache::has($this->hash.'-'.$import)) {
|
||||
Cache::put($this->hash.'-'.$import, Cache::get($this->hash.'-'.$import), 60*60*24*2);
|
||||
}
|
||||
}
|
||||
|
||||
$this->company->notification(new GenericNinjaAdminNotification($content))->ninja();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public function preTransform(array $data, $entity_type)
|
||||
|
||||
@@ -382,7 +382,6 @@ class Csv extends BaseImport implements ImportInterface
|
||||
|
||||
$this->entity_count['tasks'] = $task_count;
|
||||
|
||||
|
||||
}
|
||||
|
||||
public function transform(array $data)
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Import\Providers;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Invoice;
|
||||
use App\Import\Providers\BaseImport;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Services\Quickbooks\QuickbooksService;
|
||||
use App\Services\Quickbooks\Transformers\ClientTransformer;
|
||||
|
||||
@@ -32,11 +32,15 @@ class ClientTransformer extends BaseTransformer
|
||||
throw new ImportException('Client already exists');
|
||||
}
|
||||
|
||||
$address1 = data_get($data, 'Street', data_get($data, 'Address Line 1', ''));
|
||||
$address2 = data_get($data, 'Address Line 2', '');
|
||||
|
||||
return [
|
||||
'company_id' => $this->company->id,
|
||||
'name' => $this->getString($data, 'Organization'),
|
||||
'phone' => $this->getString($data, 'Phone'),
|
||||
'address1' => $this->getString($data, 'Street'),
|
||||
'address1' => $address1,
|
||||
'address2' => $address2,
|
||||
'city' => $this->getString($data, 'City'),
|
||||
'state' => $this->getString($data, 'Province/State'),
|
||||
'postal_code' => $this->getString($data, 'Postal Code'),
|
||||
|
||||
@@ -85,11 +85,11 @@ class InvoiceTransformer extends BaseTransformer
|
||||
return ($record[$field] / $record['Line Subtotal']) * 100;
|
||||
}
|
||||
|
||||
$tax_amount1 = isset($record['Tax 1 Amount']) ? $record['Tax 1 Amount'] : 0;
|
||||
$tax_amount1 = isset($record['Tax 1 Amount']) ? floatval($record['Tax 1 Amount']) : 0;
|
||||
|
||||
$tax_amount2 = isset($record['Tax 2 Amount']) ? $record['Tax 2 Amount'] : 0;
|
||||
$tax_amount2 = isset($record['Tax 2 Amount']) ? floatval($record['Tax 2 Amount']) : 0;
|
||||
|
||||
$line_total = isset($record['Line Total']) ? $record['Line Total'] : 0;
|
||||
$line_total = isset($record['Line Total']) ? floatval($record['Line Total']) : 0;
|
||||
|
||||
$subtotal = $line_total - $tax_amount2 - $tax_amount1;
|
||||
|
||||
|
||||
@@ -28,7 +28,15 @@ class InvoiceTransformer extends BaseTransformer
|
||||
*/
|
||||
public function transform($line_items_data)
|
||||
{
|
||||
$invoice_data = reset($line_items_data);
|
||||
// Handle both array of arrays and single array scenarios
|
||||
if (is_array($line_items_data) && isset($line_items_data[0]) && is_array($line_items_data[0])) {
|
||||
// Array of arrays - take the first invoice
|
||||
$invoice_data = reset($line_items_data);
|
||||
} else {
|
||||
// Single array - use as-is
|
||||
$invoice_data = $line_items_data;
|
||||
$line_items_data = [$line_items_data];
|
||||
}
|
||||
|
||||
if ($this->hasInvoice($invoice_data['Invoice Number'])) {
|
||||
throw new ImportException('Invoice number already exists');
|
||||
|
||||
@@ -98,6 +98,10 @@ class ProcessBankTransactionsNordigen implements ShouldQueue
|
||||
// UPDATE TRANSACTIONS
|
||||
try {
|
||||
$this->processTransactions();
|
||||
|
||||
// Perform Matching
|
||||
BankMatchingService::dispatch($this->company->id, $this->company->db);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
nlog("Nordigen: {$this->bank_integration->nordigen_account_id} - exited abnormally => " . $e->getMessage());
|
||||
|
||||
@@ -109,11 +113,9 @@ class ProcessBankTransactionsNordigen implements ShouldQueue
|
||||
|
||||
$this->bank_integration->company->notification(new GenericNinjaAdminNotification($content))->ninja();
|
||||
|
||||
throw $e;
|
||||
// throw $e;
|
||||
}
|
||||
|
||||
// Perform Matching
|
||||
BankMatchingService::dispatch($this->company->id, $this->company->db);
|
||||
}
|
||||
|
||||
// const DISCOVERED = 'DISCOVERED'; // Account was discovered but not yet processed
|
||||
@@ -163,8 +165,10 @@ class ProcessBankTransactionsNordigen implements ShouldQueue
|
||||
private function processTransactions()
|
||||
{
|
||||
//Get transaction count object
|
||||
$transactions = [];
|
||||
|
||||
$transactions = $this->nordigen->getTransactions($this->company, $this->bank_integration->nordigen_account_id, $this->from_date);
|
||||
|
||||
|
||||
//if no transactions, update the from_date and move on
|
||||
if (count($transactions) == 0) {
|
||||
|
||||
@@ -189,7 +193,12 @@ class ProcessBankTransactionsNordigen implements ShouldQueue
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
|
||||
if (BankTransaction::where('nordigen_transaction_id', $transaction['nordigen_transaction_id'])->where('company_id', $this->company->id)->where('bank_integration_id', $this->bank_integration->id)->where('is_deleted', 0)->withTrashed()->exists()) {
|
||||
if (BankTransaction::where('nordigen_transaction_id', $transaction['nordigen_transaction_id'])
|
||||
->where('company_id', $this->company->id)
|
||||
->where('bank_integration_id', $this->bank_integration->id)
|
||||
->where('is_deleted', 0)
|
||||
->withTrashed()
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,25 @@ class ProcessBankTransactionsYodlee implements ShouldQueue
|
||||
$now = now();
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if (BankTransaction::query()->where('transaction_id', $transaction['transaction_id'])->where('company_id', $this->company->id)->where('bank_integration_id', $this->bank_integration->id)->withTrashed()->exists()) {
|
||||
if (BankTransaction::query() //ensure we don't duplicate transactions with the same ID
|
||||
->where('transaction_id', $transaction['transaction_id'])
|
||||
->where('company_id', $this->company->id)
|
||||
->where('bank_integration_id', $this->bank_integration->id)
|
||||
->withTrashed()
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
elseif (BankTransaction::query() //ensure we don't duplicate transactions that have the same amount, currency, account type, category type, date, and description
|
||||
->where('company_id', $this->company->id)
|
||||
->where('bank_integration_id', $this->bank_integration->id)
|
||||
->where('amount', $transaction['amount'])
|
||||
->where('currency_id', $transaction['currency_id'])
|
||||
->where('account_type', $transaction['account_type'])
|
||||
->where('category_type', $transaction['category_type'])
|
||||
->where('date', $transaction['date'])
|
||||
->where('description', $transaction['description'])
|
||||
->withTrashed()
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1272,7 +1272,6 @@ class CompanyImport implements ShouldQueue
|
||||
{
|
||||
$activities = [];
|
||||
|
||||
|
||||
$this->genericNewClassImport(
|
||||
Activity::class,
|
||||
[
|
||||
@@ -1556,9 +1555,23 @@ class CompanyImport implements ShouldQueue
|
||||
{
|
||||
// foreach($this->backup_file->payments as $payment)
|
||||
foreach ((object)$this->getObject("payments") as $payment) {
|
||||
|
||||
|
||||
foreach ($payment->paymentables as $paymentable_obj) {
|
||||
|
||||
|
||||
try {
|
||||
$ppid = $this->transformId('payments', $paymentable_obj->payment_id);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// nlog($e->getMessage());
|
||||
nlog($paymentable_obj);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
$paymentable = new Paymentable();
|
||||
$paymentable->payment_id = $this->transformId('payments', $paymentable_obj->payment_id);
|
||||
$paymentable->payment_id = $ppid;
|
||||
$paymentable->paymentable_type = $paymentable_obj->paymentable_type;
|
||||
$paymentable->amount = $paymentable_obj->amount;
|
||||
$paymentable->refunded = $paymentable_obj->refunded;
|
||||
@@ -1675,7 +1688,15 @@ class CompanyImport implements ShouldQueue
|
||||
$key = $activity_invitation_key;
|
||||
}
|
||||
|
||||
$obj_array["{$value}"] = $this->transformId($key, $obj->{$value});
|
||||
if($class == 'App\Models\Activity'){
|
||||
|
||||
if(isset($this->ids[$key][$obj->{$value}]))
|
||||
$obj_array["{$value}"] = $this->ids[$key][$obj->{$value}];
|
||||
}
|
||||
else {
|
||||
$obj_array["{$value}"] = $this->transformId($key, $obj->{$value});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1978,7 +1999,11 @@ class CompanyImport implements ShouldQueue
|
||||
private function transformId(string $resource, ?string $old): ?int
|
||||
{
|
||||
|
||||
if (empty($old) || $old == 'WjnegYbwZ1') {
|
||||
if (empty($old) || in_array($old, ['WjnegYbwZ1'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if($resource == 'tasks' && in_array($old, ['WjnegnldwZ','kQBeX5layK','MVyb895dvA','OpnelpJeKB'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ class InvoiceTaxSummary implements ShouldQueue
|
||||
|
||||
public function handle()
|
||||
{
|
||||
nlog("InvoiceTaxSummary:: Starting job @ " . now()->toDateTimeString());
|
||||
$start = now();
|
||||
|
||||
$currentUtcHour = now()->hour;
|
||||
$transitioningTimezones = $this->getTransitioningTimezones($currentUtcHour);
|
||||
|
||||
@@ -56,6 +59,8 @@ class InvoiceTaxSummary implements ShouldQueue
|
||||
$this->processCompanyTaxSummary($company);
|
||||
}
|
||||
}
|
||||
|
||||
nlog("InvoiceTaxSummary:: Job completed in " . now()->diffInSeconds($start) . " seconds");
|
||||
}
|
||||
|
||||
private function getTransitioningTimezones($utcHour)
|
||||
@@ -117,7 +122,11 @@ class InvoiceTaxSummary implements ShouldQueue
|
||||
}
|
||||
|
||||
// Get companies that have timezone_id in their JSON settings matching the transitioning timezones
|
||||
return Company::whereRaw("JSON_EXTRACT(settings, '$.timezone_id') IN (" . implode(',', $timezoneIds) . ")")->get();
|
||||
$companies = Company::whereRaw("JSON_EXTRACT(settings, '$.timezone_id') IN (" . implode(',', $timezoneIds) . ")")->get();
|
||||
|
||||
nlog("InvoiceTaxSummary:: Found " . $companies->count() . " companies in timezones: " . implode(',', $timezoneIds));
|
||||
|
||||
return $companies;
|
||||
}
|
||||
|
||||
private function processCompanyTaxSummary($company)
|
||||
|
||||
283
app/Jobs/Invoice/InvoiceCheckOverdue.php
Normal file
283
app/Jobs/Invoice/InvoiceCheckOverdue.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Jobs\Invoice;
|
||||
|
||||
use App\Utils\Ninja;
|
||||
use App\Utils\Number;
|
||||
use App\Models\Company;
|
||||
use App\Models\Invoice;
|
||||
use App\Libraries\MultiDB;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use App\Jobs\Mail\NinjaMailer;
|
||||
use Illuminate\Support\Carbon;
|
||||
use App\Utils\Traits\MakesDates;
|
||||
use App\Jobs\Mail\NinjaMailerJob;
|
||||
use App\Jobs\Mail\NinjaMailerObject;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use App\Mail\Admin\InvoiceOverdueObject;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use App\Mail\Admin\InvoiceOverdueSummaryObject;
|
||||
use App\Utils\Traits\Notifications\UserNotifies;
|
||||
|
||||
class InvoiceCheckOverdue implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
use UserNotifies;
|
||||
use MakesDates;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
if (! config('ninja.db.multi_db_enabled')) {
|
||||
$this->processOverdueInvoices();
|
||||
} else {
|
||||
foreach (MultiDB::$dbs as $db) {
|
||||
MultiDB::setDB($db);
|
||||
$this->processOverdueInvoices();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process overdue invoices for the current database connection.
|
||||
* We check each company's timezone to ensure the invoice is truly overdue
|
||||
* based on the company's local time.
|
||||
*/
|
||||
private function processOverdueInvoices(): void
|
||||
{
|
||||
// Get all companies that are not disabled
|
||||
Company::query()
|
||||
->where('is_disabled', false)
|
||||
->when(Ninja::isHosted(), function ($query) {
|
||||
$query->whereHas('account', function ($q) {
|
||||
$q->where('is_flagged', false)
|
||||
->whereIn('plan', ['enterprise', 'pro'])
|
||||
->where('plan_expires', '>', now()->subHours(12));
|
||||
});
|
||||
})
|
||||
->cursor()
|
||||
->each(function (Company $company) {
|
||||
$this->checkCompanyOverdueInvoices($company);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for overdue invoices for a specific company,
|
||||
* using the company's timezone to determine if the invoice is overdue.
|
||||
*
|
||||
* Two scenarios trigger an overdue notification:
|
||||
* 1. partial > 0 && partial_due_date was yesterday (partial payment is overdue)
|
||||
* 2. partial == 0 && balance > 0 && due_date was yesterday (full invoice is overdue)
|
||||
*
|
||||
* To prevent duplicate notifications when running hourly, we only process
|
||||
* a company when it's currently between midnight and 1am in their timezone.
|
||||
* This ensures each company is only checked once per day.
|
||||
*/
|
||||
private function checkCompanyOverdueInvoices(Company $company): void
|
||||
{
|
||||
// Get the company's timezone
|
||||
$timezone = $company->timezone();
|
||||
$timezone_name = $timezone ? $timezone->name : 'UTC';
|
||||
|
||||
// Get the current hour in the company's timezone
|
||||
$now_in_company_tz = Carbon::now($timezone_name);
|
||||
|
||||
// Only process this company if it's currently between midnight and 1am in their timezone
|
||||
// This prevents duplicate notifications when running hourly across all timezones
|
||||
if ($now_in_company_tz->hour !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Yesterday's date in the company's timezone (Y-m-d format)
|
||||
$yesterday = $now_in_company_tz->copy()->subDay()->format('Y-m-d');
|
||||
|
||||
$overdue_invoices = Invoice::query()
|
||||
->where('company_id', $company->id)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('is_deleted', false)
|
||||
->whereNull('deleted_at')
|
||||
->where('balance', '>', 0)
|
||||
->whereHas('client', function ($query) {
|
||||
$query->where('is_deleted', 0)
|
||||
->whereNull('deleted_at');
|
||||
})
|
||||
// Check for overdue conditions based on partial or full invoice
|
||||
->where(function ($query) use ($yesterday) {
|
||||
// Case 1: Partial payment is overdue (partial > 0 and partial_due_date was yesterday)
|
||||
$query->where(function ($q) use ($yesterday) {
|
||||
$q->where('partial', '>', 0)
|
||||
->where('partial_due_date', $yesterday);
|
||||
})
|
||||
// Case 2: Full invoice is overdue (partial == 0 and due_date was yesterday)
|
||||
->orWhere(function ($q) use ($yesterday) {
|
||||
$q->where(function ($subq) {
|
||||
$subq->where('partial', '=', 0)
|
||||
->orWhereNull('partial');
|
||||
})
|
||||
->where('due_date', $yesterday);
|
||||
});
|
||||
})
|
||||
->cursor()
|
||||
->map(function ($invoice){
|
||||
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'client' => $invoice->client->present()->name(),
|
||||
'number' => $invoice->number,
|
||||
'amount' => max($invoice->partial, $invoice->balance),
|
||||
'due_date' => $invoice->due_date,
|
||||
'formatted_amount' => Number::formatMoney($invoice->balance, $invoice->client),
|
||||
'formatted_due_date' => $this->translateDate($invoice->due_date, $invoice->company->date_format(), $invoice->company->locale()),
|
||||
];
|
||||
|
||||
})
|
||||
->toArray();
|
||||
|
||||
$this->sendOverdueNotifications($overdue_invoices, $company);
|
||||
|
||||
// ->each(function ($invoice) {
|
||||
// $this->notifyOverdueInvoice($invoice);
|
||||
// });
|
||||
}
|
||||
|
||||
private function sendOverdueNotifications(array $overdue_invoices, Company $company): void
|
||||
{
|
||||
|
||||
if(empty($overdue_invoices)){
|
||||
return;
|
||||
}
|
||||
|
||||
$nmo = new NinjaMailerObject();
|
||||
$nmo->company = $company;
|
||||
$nmo->settings = $company->settings;
|
||||
|
||||
/* We loop through each user and determine whether they need to be notified */
|
||||
foreach ($company->company_users as $company_user) {
|
||||
/* The User */
|
||||
$user = $company_user->user;
|
||||
|
||||
if (! $user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$overdue_invoices_collection = $overdue_invoices;
|
||||
|
||||
$invoice = Invoice::withTrashed()->find($overdue_invoices[0]['id']);
|
||||
|
||||
$table_headers = [
|
||||
'client' => ctrans('texts.client'),
|
||||
'number' => ctrans('texts.invoice_number'),
|
||||
'formatted_due_date' => ctrans('texts.due_date'),
|
||||
'formatted_amount' => ctrans('texts.amount'),
|
||||
];
|
||||
|
||||
/** filter down the set if the user only has notifications for their own invoices */
|
||||
if(isset($company_user->notifications->email) && is_array($company_user->notifications->email) && in_array('invoice_late_user', $company_user->notifications->email)){
|
||||
|
||||
$overdue_invoices_collection = collect($overdue_invoices)
|
||||
->filter(function ($overdue_invoice) use ($user) {
|
||||
$invoice = Invoice::withTrashed()->find($overdue_invoice['id']);
|
||||
// nlog([$invoice->user_id, $user->id, $invoice->assigned_user_id, $user->id]);
|
||||
return $invoice->user_id == $user->id || $invoice->assigned_user_id == $user->id;
|
||||
})
|
||||
->toArray();
|
||||
|
||||
if(count($overdue_invoices_collection) === 0){
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoice = Invoice::withTrashed()->find(end($overdue_invoices_collection)['id']);
|
||||
|
||||
}
|
||||
|
||||
$nmo->mailable = new NinjaMailer((new InvoiceOverdueSummaryObject($overdue_invoices_collection, $table_headers, $company, $company_user->portalType()))->build());
|
||||
|
||||
/* Returns an array of notification methods */
|
||||
$methods = $this->findUserNotificationTypes(
|
||||
$invoice->invitations()->first(),
|
||||
$company_user,
|
||||
'invoice',
|
||||
['all_notifications', 'invoice_late', 'invoice_late_all', 'invoice_late_user']
|
||||
);
|
||||
|
||||
|
||||
/* If one of the methods is email then we fire the mailer */
|
||||
if (($key = array_search('mail', $methods)) !== false) {
|
||||
unset($methods[$key]);
|
||||
|
||||
$nmo->to_user = $user;
|
||||
|
||||
NinjaMailerJob::dispatch($nmo);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notifications for an overdue invoice to all relevant company users.
|
||||
* @deprecated in favour of sendOverdueNotifications to send a summary email to all users
|
||||
*/
|
||||
/** @phpstan-ignore-next-line */
|
||||
private function notifyOverdueInvoice(Invoice $invoice): void
|
||||
{
|
||||
$nmo = new NinjaMailerObject();
|
||||
$nmo->company = $invoice->company;
|
||||
$nmo->settings = $invoice->company->settings;
|
||||
|
||||
/* We loop through each user and determine whether they need to be notified */
|
||||
foreach ($invoice->company->company_users as $company_user) {
|
||||
/* The User */
|
||||
$user = $company_user->user;
|
||||
|
||||
if (! $user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$nmo->mailable = new NinjaMailer((new InvoiceOverdueObject($invoice, $invoice->company, $company_user->portalType()))->build());
|
||||
|
||||
/* Returns an array of notification methods */
|
||||
$methods = $this->findUserNotificationTypes(
|
||||
$invoice->invitations()->first(),
|
||||
$company_user,
|
||||
'invoice',
|
||||
['all_notifications', 'invoice_late', 'invoice_late_all', 'invoice_late_user']
|
||||
);
|
||||
|
||||
/* If one of the methods is email then we fire the mailer */
|
||||
if (($key = array_search('mail', $methods)) !== false) {
|
||||
unset($methods[$key]);
|
||||
|
||||
$nmo->to_user = $user;
|
||||
|
||||
NinjaMailerJob::dispatch($nmo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,7 +825,7 @@ class NinjaMailerJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/* Ensure the user has a valid email address */
|
||||
if (!str_contains($this->nmo->to_user->email, "@")) {
|
||||
if (!str_contains($this->nmo->to_user->email ?? '', "@")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ class MailWebhookSync implements ShouldQueue
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
|
||||
if (! Ninja::isHosted()) {
|
||||
return;
|
||||
}
|
||||
@@ -131,7 +132,13 @@ class MailWebhookSync implements ShouldQueue
|
||||
} catch (\Throwable $th) {
|
||||
$token = config('services.postmark-outlook.token');
|
||||
$postmark = new \Postmark\PostmarkClient($token);
|
||||
$messageDetail = $postmark->getOutboundMessageDetails($invite->message_id);
|
||||
|
||||
try {
|
||||
$messageDetail = $postmark->getOutboundMessageDetails($invite->message_id);
|
||||
} catch (\Throwable $th){
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -64,7 +64,12 @@ class TaskScheduler implements ShouldQueue
|
||||
//@var \App\Models\Schedule $scheduler
|
||||
$scheduler->service()->runTask();
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
nlog("Exception:: TaskScheduler:: Doing job :: {$scheduler->id} :: {$scheduler->name}" . $e->getMessage());
|
||||
|
||||
if (app()->bound('sentry')) {
|
||||
app('sentry')->captureException($e);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -62,6 +62,9 @@ class ProcessPostmarkWebhook implements ShouldQueue
|
||||
*/
|
||||
public function __construct(private array $request, private string $security_token)
|
||||
{
|
||||
if(\App\Utils\Ninja::isHosted()){
|
||||
$this->onQueue('postmark');
|
||||
}
|
||||
}
|
||||
|
||||
private function getSystemLog(string $message_id): ?SystemLog
|
||||
@@ -362,7 +365,7 @@ class ProcessPostmarkWebhook implements ShouldQueue
|
||||
|
||||
try {
|
||||
$messageDetail = $postmark->getOutboundMessageDetails($message_id);
|
||||
} catch (\Exception $e) {
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
$postmark_secret = config('services.postmark-outlook.token');
|
||||
$postmark = new PostmarkClient($postmark_secret);
|
||||
@@ -380,7 +383,6 @@ class ProcessPostmarkWebhook implements ShouldQueue
|
||||
|
||||
$messageDetail = $this->getRawMessage($message_id);
|
||||
|
||||
|
||||
$event = collect($messageDetail->messageevents)->first(function ($event) {
|
||||
|
||||
return $event?->Details?->BounceID ?? false;
|
||||
@@ -423,7 +425,7 @@ class ProcessPostmarkWebhook implements ShouldQueue
|
||||
'delivery_message' => $event->Details->DeliveryMessage ?? $event->Details->Summary ?? '',
|
||||
'server' => $event->Details->DestinationServer ?? '',
|
||||
'server_ip' => $event->Details->DestinationIP ?? '',
|
||||
'date' => \Carbon\Carbon::parse($event->ReceivedAt)->format('Y-m-d H:i:s') ?? '',
|
||||
'date' => \Carbon\Carbon::parse($event->ReceivedAt)->setTimezone($this->invitation->company->timezone()->name)->format('Y-m-d H:i:s') ?? '',
|
||||
];
|
||||
|
||||
})->toArray();
|
||||
@@ -443,12 +445,6 @@ class ProcessPostmarkWebhook implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
// public function middleware()
|
||||
// {
|
||||
// $key = $this->request['MessageID'] ?? '' . $this->request['Tag'] ?? '';
|
||||
// return [(new \Illuminate\Queue\Middleware\WithoutOverlapping($key))->releaseAfter(60)];
|
||||
// }
|
||||
|
||||
public function failed($exception = null)
|
||||
{
|
||||
|
||||
|
||||
176
app/Jobs/Quickbooks/PushToQuickbooks.php
Normal file
176
app/Jobs/Quickbooks/PushToQuickbooks.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Jobs\Quickbooks;
|
||||
|
||||
use App\Libraries\MultiDB;
|
||||
use App\Models\Client;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Company;
|
||||
use App\Services\Quickbooks\QuickbooksService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Unified job to push entities to QuickBooks.
|
||||
*
|
||||
* This job handles pushing different entity types (clients, invoices, etc.) to QuickBooks.
|
||||
* It is dispatched from model observers when:
|
||||
* - QuickBooks is configured
|
||||
* - Push events are enabled for the entity/action
|
||||
* - Sync direction allows push
|
||||
*/
|
||||
class PushToQuickbooks implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $tries = 3;
|
||||
|
||||
public $deleteWhenMissingModels = true;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param string $entity_type Entity type: 'client', 'invoice', etc.
|
||||
* @param int $entity_id The ID of the entity to push
|
||||
* @param int $company_id The company ID
|
||||
* @param string $db The database name
|
||||
* @param string $action Action type: 'create', 'update', 'status'
|
||||
* @param string|null $status Optional status for status-based pushes (e.g., invoice status: 'draft', 'sent', 'paid', 'deleted')
|
||||
*/
|
||||
public function __construct(
|
||||
private string $entity_type,
|
||||
private int $entity_id,
|
||||
private int $company_id,
|
||||
private string $db,
|
||||
private string $action,
|
||||
private ?string $status = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
MultiDB::setDb($this->db);
|
||||
|
||||
$company = Company::find($this->company_id);
|
||||
|
||||
if (!$company) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the entity based on type
|
||||
$entity = $this->resolveEntity($this->entity_type, $this->entity_id);
|
||||
|
||||
if (!$entity) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-check push is still enabled (settings might have changed)
|
||||
if (!$this->shouldPush($company, $this->entity_type, $this->action, $this->status)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qbService = new QuickbooksService($company);
|
||||
|
||||
// Dispatch to appropriate handler based on entity type
|
||||
match($this->entity_type) {
|
||||
'client' => $this->pushClient($qbService, $entity),
|
||||
'invoice' => $this->pushInvoice($qbService, $entity),
|
||||
default => nlog("QuickBooks: Unsupported entity type: {$this->entity_type}"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the entity model based on type.
|
||||
*
|
||||
* @param string $entity_type
|
||||
* @param int $entity_id
|
||||
* @return Client|Invoice|null
|
||||
*/
|
||||
private function resolveEntity(string $entity_type, int $entity_id): Client|Invoice|null
|
||||
{
|
||||
return match($entity_type) {
|
||||
'client' => Client::find($entity_id),
|
||||
'invoice' => Invoice::find($entity_id),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if push should still occur (settings might have changed since job was queued).
|
||||
*
|
||||
* @param Company $company
|
||||
* @param string $entity_type
|
||||
* @param string $action
|
||||
* @param string|null $status
|
||||
* @return bool
|
||||
*/
|
||||
private function shouldPush(Company $company, string $entity_type, string $action, ?string $status): bool
|
||||
{
|
||||
return $company->shouldPushToQuickbooks($entity_type, $action, $status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a client to QuickBooks.
|
||||
*
|
||||
* @param QuickbooksService $qbService
|
||||
* @param Client $client
|
||||
* @return void
|
||||
*/
|
||||
private function pushClient(QuickbooksService $qbService, Client $client): void
|
||||
{
|
||||
// TODO: Implement actual push logic
|
||||
// $qbService->client->push($client, $this->action);
|
||||
|
||||
nlog("QuickBooks: Pushing client {$client->id} to QuickBooks ({$this->action})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an invoice to QuickBooks.
|
||||
*
|
||||
* @param QuickbooksService $qbService
|
||||
* @param Invoice $invoice
|
||||
* @return void
|
||||
*/
|
||||
private function pushInvoice(QuickbooksService $qbService, Invoice $invoice): void
|
||||
{
|
||||
// Use syncToForeign to push the invoice
|
||||
$qbService->invoice->syncToForeign([$invoice]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map invoice status_id and is_deleted to status string.
|
||||
*
|
||||
* @param int $statusId
|
||||
* @param bool $isDeleted
|
||||
* @return string
|
||||
*/
|
||||
private function mapInvoiceStatusToString(int $statusId, bool $isDeleted): string
|
||||
{
|
||||
if ($isDeleted) {
|
||||
return 'deleted';
|
||||
}
|
||||
|
||||
return match($statusId) {
|
||||
\App\Models\Invoice::STATUS_DRAFT => 'draft',
|
||||
\App\Models\Invoice::STATUS_SENT => 'sent',
|
||||
\App\Models\Invoice::STATUS_PAID => 'paid',
|
||||
default => 'unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -472,16 +472,56 @@ class Import implements ShouldQueue
|
||||
$company_repository->save($data, $this->company);
|
||||
|
||||
if (isset($data['settings']->company_logo) && strlen($data['settings']->company_logo) > 0) {
|
||||
|
||||
|
||||
try {
|
||||
$tempImage = tempnam(sys_get_temp_dir(), basename($data['settings']->company_logo));
|
||||
copy($data['settings']->company_logo, $tempImage);
|
||||
$this->uploadLogo($tempImage, $this->company, $this->company);
|
||||
$logoUrl = $data['settings']->company_logo;
|
||||
|
||||
// 1. Validate URL format
|
||||
if (!filter_var($logoUrl, FILTER_VALIDATE_URL)) {
|
||||
throw new \Exception('Invalid URL format');
|
||||
}
|
||||
|
||||
// 2. Restrict protocols
|
||||
$parsed = parse_url($logoUrl);
|
||||
if (!in_array($parsed['scheme'] ?? '', ['http', 'https'])) {
|
||||
throw new \Exception('Only HTTP/HTTPS allowed');
|
||||
}
|
||||
|
||||
// 3. Block internal/private IPs (SSRF protection)
|
||||
$host = $parsed['host'] ?? '';
|
||||
$ip = gethostbyname($host);
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
|
||||
throw new \Exception('Internal hosts not allowed');
|
||||
}
|
||||
|
||||
// 4. Use HTTP client with timeout and size limits instead of copy()
|
||||
$response = \Illuminate\Support\Facades\Http::timeout(20)->get($logoUrl);
|
||||
|
||||
if ($response->successful() && strlen($response->body()) < 20 * 1024 * 1024) { // 5MB limit
|
||||
$tempImage = tempnam(sys_get_temp_dir(), 'logo_');
|
||||
file_put_contents($tempImage, $response->body());
|
||||
$this->uploadLogo($tempImage, $this->company, $this->company);
|
||||
@unlink($tempImage); // Cleanup
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$settings = $this->company->settings;
|
||||
$settings->company_logo = '';
|
||||
$this->company->settings = $settings;
|
||||
$this->company->save();
|
||||
nlog("Logo import failed: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// try {
|
||||
// $tempImage = tempnam(sys_get_temp_dir(), basename($data['settings']->company_logo));
|
||||
// copy($data['settings']->company_logo, $tempImage);
|
||||
// $this->uploadLogo($tempImage, $this->company, $this->company);
|
||||
// } catch (\Exception $e) {
|
||||
// $settings = $this->company->settings;
|
||||
// $settings->company_logo = '';
|
||||
// $this->company->settings = $settings;
|
||||
// $this->company->save();
|
||||
// }
|
||||
}
|
||||
|
||||
Company::reguard();
|
||||
|
||||
61
app/Listeners/Quote/QuoteRejectedActivity.php
Normal file
61
app/Listeners/Quote/QuoteRejectedActivity.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Listeners\Quote;
|
||||
|
||||
use App\Libraries\MultiDB;
|
||||
use App\Models\Activity;
|
||||
use App\Repositories\ActivityRepository;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use stdClass;
|
||||
|
||||
class QuoteRejectedActivity implements ShouldQueue
|
||||
{
|
||||
protected $activity_repo;
|
||||
|
||||
public $delay = 5;
|
||||
|
||||
/**
|
||||
* Create the event listener.
|
||||
*
|
||||
* @param ActivityRepository $activity_repo
|
||||
*/
|
||||
public function __construct(ActivityRepository $activity_repo)
|
||||
{
|
||||
$this->activity_repo = $activity_repo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*
|
||||
* @param object $event
|
||||
* @return void
|
||||
*/
|
||||
public function handle($event)
|
||||
{
|
||||
MultiDB::setDb($event->company->db);
|
||||
|
||||
$fields = new stdClass();
|
||||
|
||||
$user_id = isset($event->event_vars['user_id']) ? $event->event_vars['user_id'] : $event->quote->user_id;
|
||||
|
||||
$fields->user_id = $user_id;
|
||||
$fields->quote_id = $event->quote->id;
|
||||
$fields->client_id = $event->quote->client_id;
|
||||
$fields->client_contact_id = $event->contact->id;
|
||||
$fields->company_id = $event->quote->company_id;
|
||||
$fields->activity_type_id = Activity::QUOTE_REJECTED;
|
||||
$fields->notes = $event->notes ?? '';
|
||||
|
||||
$this->activity_repo->save($fields, $event->quote, $event->event_vars);
|
||||
}
|
||||
}
|
||||
80
app/Listeners/Quote/QuoteRejectedNotification.php
Normal file
80
app/Listeners/Quote/QuoteRejectedNotification.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Quote Ninja (https://quoteninja.com).
|
||||
*
|
||||
* @link https://github.com/quoteninja/quoteninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2022. Quote Ninja LLC (https://quoteninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Listeners\Quote;
|
||||
|
||||
use App\Libraries\MultiDB;
|
||||
use App\Jobs\Mail\NinjaMailer;
|
||||
use App\Jobs\Mail\NinjaMailerJob;
|
||||
use App\Jobs\Mail\NinjaMailerObject;
|
||||
use App\Mail\Admin\QuoteRejectedObject;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use App\Utils\Traits\Notifications\UserNotifies;
|
||||
|
||||
class QuoteRejectedNotification implements ShouldQueue
|
||||
{
|
||||
use UserNotifies;
|
||||
|
||||
public $delay = 8;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the event.
|
||||
*
|
||||
* @param object $event
|
||||
* @return void
|
||||
*/
|
||||
public function handle($event)
|
||||
{
|
||||
MultiDB::setDb($event->company->db);
|
||||
|
||||
$first_notification_sent = true;
|
||||
|
||||
$quote = $event->quote;
|
||||
|
||||
|
||||
/* We loop through each user and determine whether they need to be notified */
|
||||
foreach ($event->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_rejected', 'quote_rejected_all', 'quote_rejected_user']);
|
||||
|
||||
/* If one of the methods is email then we fire the EntitySentMailer */
|
||||
if (($key = array_search('mail', $methods)) !== false) {
|
||||
unset($methods[$key]);
|
||||
|
||||
$nmo = new NinjaMailerObject();
|
||||
$nmo->mailable = new NinjaMailer((new QuoteRejectedObject($quote, $event->company, $company_user->portalType(), $event->notes))->build());
|
||||
$nmo->company = $quote->company;
|
||||
$nmo->settings = $quote->company->settings;
|
||||
|
||||
$nmo->to_user = $user;
|
||||
|
||||
(new NinjaMailerJob($nmo))->handle();
|
||||
|
||||
$nmo = null;
|
||||
|
||||
/* This prevents more than one notification being sent */
|
||||
$first_notification_sent = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,8 +235,6 @@ class InvoicePay extends Component
|
||||
public function mount()
|
||||
{
|
||||
|
||||
// $this->resetContext();
|
||||
|
||||
MultiDB::setDb($this->db);
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
@@ -302,6 +300,8 @@ class InvoicePay extends Component
|
||||
|
||||
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
{
|
||||
MultiDB::setDb($this->db);
|
||||
|
||||
//@phpstan-ignore-next-line
|
||||
$invite = \App\Models\InvoiceInvitation::with('contact.client', 'company')->withTrashed()->find($this->invitation_id);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ namespace App\Livewire\Flow2;
|
||||
use Livewire\Component;
|
||||
use App\Libraries\MultiDB;
|
||||
use App\Models\CompanyGateway;
|
||||
use Livewire\Attributes\Computed;
|
||||
use App\Services\Client\RFFService;
|
||||
use App\Utils\Traits\WithSecureContext;
|
||||
|
||||
|
||||
@@ -88,6 +88,10 @@ class QuotesTable extends Component
|
||||
if (in_array('3', $this->status)) {
|
||||
$query->whereIn('status_id', [Quote::STATUS_APPROVED, Quote::STATUS_CONVERTED]);
|
||||
}
|
||||
|
||||
if (in_array('5', $this->status)) {
|
||||
$query->where('status_id', Quote::STATUS_REJECTED);
|
||||
}
|
||||
}
|
||||
|
||||
$query = $query
|
||||
|
||||
@@ -46,6 +46,31 @@ class EntityFailedSendObject
|
||||
{
|
||||
$this->invitation = $invitation;
|
||||
$this->entity_type = $entity_type;
|
||||
|
||||
// Load relationships if they're not already loaded (e.g., when withoutRelations() was called)
|
||||
if (!$invitation->relationLoaded('contact')) {
|
||||
$invitation->load('contact');
|
||||
}
|
||||
if (!$invitation->relationLoaded('company')) {
|
||||
$invitation->load('company.account');
|
||||
} else {
|
||||
// If company is loaded, ensure account is also loaded
|
||||
if ($invitation->company && !$invitation->company->relationLoaded('account')) {
|
||||
$invitation->company->load('account');
|
||||
}
|
||||
}
|
||||
if (!$invitation->relationLoaded($entity_type)) {
|
||||
$invitation->load([$entity_type => function ($query) {
|
||||
$query->with('client');
|
||||
}]);
|
||||
} else {
|
||||
// If entity is loaded, ensure client is also loaded
|
||||
$entity = $invitation->{$entity_type};
|
||||
if ($entity && !$entity->relationLoaded('client')) {
|
||||
$entity->load('client');
|
||||
}
|
||||
}
|
||||
|
||||
$this->entity = $invitation->{$entity_type};
|
||||
$this->contact = $invitation->contact;
|
||||
$this->company = $invitation->company;
|
||||
|
||||
102
app/Mail/Admin/InvoiceOverdueObject.php
Normal file
102
app/Mail/Admin/InvoiceOverdueObject.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. 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\Invoice;
|
||||
use App\Utils\Ninja;
|
||||
use App\Utils\Number;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use stdClass;
|
||||
|
||||
class InvoiceOverdueObject
|
||||
{
|
||||
public function __construct(public Invoice $invoice, public Company $company, public bool $use_react_url)
|
||||
{
|
||||
}
|
||||
|
||||
public function build()
|
||||
{
|
||||
MultiDB::setDb($this->company->db);
|
||||
|
||||
if (! $this->invoice) {
|
||||
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;
|
||||
$mail_obj->text_view = 'email.template.text';
|
||||
|
||||
return $mail_obj;
|
||||
}
|
||||
|
||||
private function getAmount()
|
||||
{
|
||||
return Number::formatMoney($this->invoice->balance, $this->invoice->client);
|
||||
}
|
||||
|
||||
private function getSubject()
|
||||
{
|
||||
return
|
||||
ctrans(
|
||||
'texts.notification_invoice_overdue_subject',
|
||||
[
|
||||
'client' => $this->invoice->client->present()->name(),
|
||||
'invoice' => $this->invoice->number,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function getData()
|
||||
{
|
||||
$settings = $this->invoice->client->getMergedSettings();
|
||||
$content = ctrans(
|
||||
'texts.notification_invoice_overdue',
|
||||
[
|
||||
'amount' => $this->getAmount(),
|
||||
'client' => $this->invoice->client->present()->name(),
|
||||
'invoice' => $this->invoice->number,
|
||||
]
|
||||
);
|
||||
|
||||
$data = [
|
||||
'title' => $this->getSubject(),
|
||||
'content' => $content,
|
||||
'url' => $this->invoice->invitations->first()->getAdminLink($this->use_react_url),
|
||||
'button' => $this->use_react_url ? ctrans('texts.view_invoice') : ctrans('texts.login'),
|
||||
'signature' => $settings->email_signature,
|
||||
'logo' => $this->company->present()->logo(),
|
||||
'settings' => $settings,
|
||||
'whitelabel' => $this->company->account->isPaid() ? true : false,
|
||||
'text_body' => $content,
|
||||
'template' => $this->company->account->isPremium() ? 'email.template.admin_premium' : 'email.template.admin',
|
||||
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
103
app/Mail/Admin/InvoiceOverdueSummaryObject.php
Normal file
103
app/Mail/Admin/InvoiceOverdueSummaryObject.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Mail\Admin;
|
||||
|
||||
use stdClass;
|
||||
use Carbon\Carbon;
|
||||
use App\Utils\Ninja;
|
||||
use App\Utils\Number;
|
||||
use App\Models\Company;
|
||||
use App\Models\Invoice;
|
||||
use App\Libraries\MultiDB;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use App\Utils\Traits\MakesDates;
|
||||
|
||||
class InvoiceOverdueSummaryObject
|
||||
{
|
||||
use MakesDates;
|
||||
|
||||
public function __construct(public array $overdue_invoices, public array $table_headers, public Company $company, public bool $use_react_url)
|
||||
{
|
||||
}
|
||||
|
||||
public function build()
|
||||
{
|
||||
MultiDB::setDb($this->company->db);
|
||||
|
||||
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 = 0;
|
||||
$mail_obj->subject = $this->getSubject();
|
||||
$mail_obj->data = $this->getData();
|
||||
$mail_obj->markdown = 'email.admin.generic_table';
|
||||
$mail_obj->tag = $this->company->company_key;
|
||||
$mail_obj->text_view = 'email.admin.generic_table_text';
|
||||
|
||||
return $mail_obj;
|
||||
}
|
||||
|
||||
private function getSubject()
|
||||
{
|
||||
|
||||
$timezone = $this->company->timezone();
|
||||
$timezone_name = $timezone ? $timezone->name : 'UTC';
|
||||
|
||||
// Get the current hour in the company's timezone
|
||||
$now_in_company_tz = Carbon::now($timezone_name);
|
||||
$date = $this->translateDate($now_in_company_tz->format('Y-m-d'), $this->company->date_format(), $this->company->locale());
|
||||
|
||||
return
|
||||
ctrans(
|
||||
'texts.notification_invoice_overdue_summary_subject',
|
||||
[
|
||||
'date' => $date
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function getData()
|
||||
{
|
||||
|
||||
$invoice = Invoice::withTrashed()->find(reset($this->overdue_invoices)['id']);
|
||||
|
||||
$overdue_invoices_collection = array_map(
|
||||
fn($row) => \Illuminate\Support\Arr::except($row, ['id', 'amount', 'due_date']),
|
||||
$this->overdue_invoices
|
||||
);
|
||||
|
||||
$data = [
|
||||
'title' => $this->getSubject(),
|
||||
'content' => ctrans('texts.notification_invoice_overdue_summary'),
|
||||
'url' => $invoice->invitations->first()->getAdminLink($this->use_react_url),
|
||||
'button' => $this->use_react_url ? ctrans('texts.view_invoice') : ctrans('texts.login'),
|
||||
'signature' => $this->company->settings->email_signature,
|
||||
'logo' => $this->company->present()->logo(),
|
||||
'settings' => $this->company->settings,
|
||||
'whitelabel' => $this->company->account->isPaid() ? true : false,
|
||||
'text_body' => ctrans('texts.notification_invoice_overdue_summary'),
|
||||
'template' => $this->company->account->isPremium() ? 'email.template.admin_premium' : 'email.template.admin',
|
||||
'table' => $overdue_invoices_collection,
|
||||
'table_headers' => $this->table_headers,
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
101
app/Mail/Admin/QuoteRejectedObject.php
Normal file
101
app/Mail/Admin/QuoteRejectedObject.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. 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 QuoteRejectedObject
|
||||
{
|
||||
public function __construct(public Quote $quote, public Company $company, public bool $use_react_url, public string $notes)
|
||||
{
|
||||
}
|
||||
|
||||
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;
|
||||
$mail_obj->text_view = 'email.template.text';
|
||||
|
||||
return $mail_obj;
|
||||
}
|
||||
|
||||
private function getAmount()
|
||||
{
|
||||
return Number::formatMoney($this->quote->amount, $this->quote->client);
|
||||
}
|
||||
|
||||
private function getSubject()
|
||||
{
|
||||
return
|
||||
ctrans(
|
||||
'texts.notification_quote_rejected_subject',
|
||||
[
|
||||
'client' => $this->quote->client->present()->name(),
|
||||
'quote' => $this->quote->number,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function getData()
|
||||
{
|
||||
$settings = $this->quote->client->getMergedSettings();
|
||||
$content = ctrans(
|
||||
'texts.notification_quote_rejected',
|
||||
[
|
||||
'amount' => $this->getAmount(),
|
||||
'client' => $this->quote->client->present()->name(),
|
||||
'quote' => $this->quote->number,
|
||||
'notes' => $this->notes,
|
||||
]
|
||||
);
|
||||
|
||||
$data = [
|
||||
'title' => $this->getSubject(),
|
||||
'content' => $content,
|
||||
'url' => $this->quote->invitations->first()->getAdminLink($this->use_react_url),
|
||||
'button' => ctrans('texts.view_quote'),
|
||||
'signature' => $settings->email_signature,
|
||||
'logo' => $this->company->present()->logo(),
|
||||
'settings' => $settings,
|
||||
'whitelabel' => $this->company->account->isPaid() ? true : false,
|
||||
'text_body' => $content,
|
||||
'template' => $this->company->account->isPremium() ? 'email.template.admin_premium' : 'email.template.admin',
|
||||
];
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user