Merge pull request #11613 from turbo124/v5-stable

v5.12.50
This commit is contained in:
David Bomba
2026-01-29 07:50:55 +11:00
committed by GitHub
257 changed files with 17184 additions and 2595 deletions

View File

@@ -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.

View File

@@ -1 +1 @@
5.12.38
5.12.50

View File

@@ -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);
}
}

View File

@@ -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();

View 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,
];
}
}

View File

@@ -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);
}
}

View File

@@ -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(),
];
}
}

View File

@@ -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,
];
}
}

View File

@@ -236,7 +236,7 @@ class PayPalBalanceAffecting
// $csv = Reader::createFromString($csvFile);
// $csv = Reader::fromString($csvFile);
// // $csvdelimiter = self::detectDelimiter($csvfile);
// $csv->setDelimiter(",");
// $stmt = new Statement();

View File

@@ -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.
}

View 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;
}
}

View File

@@ -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 = [

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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']));

View File

@@ -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;

View File

@@ -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') {

View File

@@ -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
*

View File

@@ -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') {

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View 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.
*

View File

@@ -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'];

View File

@@ -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();

View File

@@ -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);

View File

@@ -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()

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -52,7 +52,6 @@ class EInvoicePeppolController extends BaseController
*/
public function setup(StoreEntityRequest $request, Storecove $storecove): Response|JsonResponse
{
/**
* @var \App\Models\Company
*/

View File

@@ -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'))) {

View File

@@ -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'));
}

View File

@@ -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);
}

View 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();
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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.
*

View File

@@ -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];

View File

@@ -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()

View File

@@ -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);
}

View File

@@ -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'])) {

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -118,7 +118,7 @@ class PreviewInvoiceRequest extends Request
};
if ($invitation) {
nlog($invitation->toArray());
// nlog($invitation->toArray());
return $invitation;
}

View 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',
];
}
}

View 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 [
//
];
}
}

View 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',
];
}
}

View File

@@ -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;
}
}
/**

View File

@@ -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;
}
}
/**

View File

@@ -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();

View File

@@ -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();

View File

@@ -161,6 +161,10 @@ class StoreTaskRequest extends Request
}
if(isset($input['description']) && is_string($input['description'])) {
$input['description'] = str_ireplace(['</sc', 'file:/', 'iframe', '<embed', '&lt;embed', '&lt;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();

View File

@@ -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', '&lt;embed', '&lt;object', '<object', '127.0.0.1', 'localhost', '<?xml encoding="UTF-8">', '/etc/'], "", $input['description']);
}
if (isset($input['documents'])) {
unset($input['documents']);
}

View 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;
}
}

View File

@@ -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)) {

View File

@@ -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",

View 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;
}
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -382,7 +382,6 @@ class Csv extends BaseImport implements ImportInterface
$this->entity_count['tasks'] = $task_count;
}
public function transform(array $data)

View File

@@ -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;

View File

@@ -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'),

View File

@@ -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;

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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)

View 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);
}
}
}
}

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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);
}
}
});

View File

@@ -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)
{

View 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',
};
}
}

View File

@@ -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();

View 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);
}
}

View 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;
}
}
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View 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;
}
}

View 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;
}
}

View 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