Merge pull request #11722 from turbo124/v5-develop

v5.12.66
This commit is contained in:
David Bomba
2026-02-24 19:12:15 +11:00
committed by GitHub
53 changed files with 980 additions and 691 deletions

View File

@@ -1 +1 @@
5.12.65
5.12.66

View File

@@ -43,6 +43,7 @@ class InvoiceSyncCast implements CastsAttributes
'qb_id' => $value->qb_id,
'invitations' => $value->invitations,
'dn_completed' => $value->dn_completed,
'dn_document_hashed_id' => $value->dn_document_hashed_id,
])
];
}

View File

@@ -0,0 +1,50 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Casts;
use App\DataMapper\PurchaseOrderSync;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class PurchaseOrderSyncCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return null; // Return null if the value is null
}
$data = json_decode($value, true);
if (!is_array($data) || empty($data)) {
return null; // Return null if decoded data is not an array or is empty
}
return PurchaseOrderSync::fromArray($data);
}
public function set($model, string $key, $value, array $attributes)
{
if (is_null($value)) {
return [$key => null];
}
return [
$key => json_encode([
'qb_id' => $value->qb_id,
'invitations' => $value->invitations,
'dn_completed' => $value->dn_completed,
'dn_document_hashed_id' => $value->dn_document_hashed_id,
])
];
}
}

View File

@@ -43,6 +43,7 @@ class QuoteSyncCast implements CastsAttributes
'qb_id' => $value->qb_id,
'invitations' => $value->invitations,
'dn_completed' => $value->dn_completed,
'dn_document_hashed_id' => $value->dn_document_hashed_id,
])
];
}

View File

@@ -26,6 +26,7 @@ class InvoiceSync implements Castable
public string $qb_id = '',
public array $invitations = [],
public bool $dn_completed = false,
public string $dn_document_hashed_id = '',
){}
/**
* Get the name of the caster class to use when casting from / to this cast target.
@@ -43,6 +44,7 @@ class InvoiceSync implements Castable
qb_id: $data['qb_id'] ?? '',
invitations: $data['invitations'] ?? [],
dn_completed: $data['dn_completed'] ?? false,
dn_document_hashed_id: $data['dn_document_hashed_id'] ?? '',
);
}

View File

@@ -0,0 +1,100 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\DataMapper;
use App\Casts\PurchaseOrderSyncCast;
use Illuminate\Contracts\Database\Eloquent\Castable;
/**
* PurchaseOrderSync.
*/
class PurchaseOrderSync implements Castable
{
public function __construct(
public string $qb_id = '',
public array $invitations = [],
public bool $dn_completed = false,
public string $dn_document_hashed_id = '',
){}
/**
* Get the name of the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
*/
public static function castUsing(array $arguments): string
{
return PurchaseOrderSyncCast::class;
}
public static function fromArray(array $data): self
{
return new self(
qb_id: $data['qb_id'] ?? '',
invitations: $data['invitations'] ?? [],
dn_completed: $data['dn_completed'] ?? false,
dn_document_hashed_id: $data['dn_document_hashed_id'] ?? '',
);
}
/**
* Add an invitation to the invitations array
*
* @param string $invitation_key The invitation key
* @param string $dn_id The DocuNinja ID
* @param string $dn_invitation_id The DocuNinja invitation ID
* @param string $dn_sig The DocuNinja signature
*/
public function addInvitation(
string $invitation_key,
string $dn_id,
string $dn_invitation_id,
string $dn_sig
): void {
$this->invitations[] = [
'invitation_key' => $invitation_key,
'dn_id' => $dn_id,
'dn_invitation_id' => $dn_invitation_id,
'dn_sig' => $dn_sig,
];
}
/**
* Get invitation data by invitation key
*
* @param string $invitation_key The invitation key
* @return array|null The invitation data or null if not found
*/
public function getInvitation(string $invitation_key): ?array
{
foreach ($this->invitations as $invitation) {
if ($invitation['invitation_key'] === $invitation_key) {
return $invitation;
}
}
return null;
}
/**
* Remove an invitation by invitation key
*
* @param string $invitation_key The invitation key
*/
public function removeInvitation(string $invitation_key): void
{
$this->invitations = array_filter($this->invitations, function($invitation) use ($invitation_key) {
return $invitation['invitation_key'] !== $invitation_key;
});
// Re-index the array to maintain numeric keys
$this->invitations = array_values($this->invitations);
}
}

View File

@@ -24,6 +24,7 @@ class QuoteSync implements Castable
public string $qb_id = '',
public array $invitations = [],
public bool $dn_completed = false,
public string $dn_document_hashed_id = '',
){}
/**
* Get the name of the caster class to use when casting from / to this cast target.
@@ -41,6 +42,7 @@ class QuoteSync implements Castable
qb_id: $data['qb_id'] ?? '',
invitations: $data['invitations'] ?? [],
dn_completed: $data['dn_completed'] ?? false,
dn_document_hashed_id: $data['dn_document_hashed_id'] ?? '',
);
}

View File

@@ -0,0 +1,52 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Events\User;
use App\Models\Company;
use App\Models\User;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* Class UserWasPurged.
*/
class UserWasPurged
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*
* @param User $admin_user
* @param string $purged_user_name
* @param Company $company
* @param array $event_vars
*/
public function __construct(public User $admin_user, public string $purged_user_name, public Company $company, public array $event_vars)
{
}
/**
* Get the channels the event should broadcast on.
*
* @return Channel|array
*/
public function broadcastOn()
{
return [];
}
}

View File

@@ -192,6 +192,9 @@ class PaymentController extends Controller
'_key' => false,
'invitation' => $invitation,
'variables' => $variables,
'hash' => false,
'docuninja_active' => false,
'requires_signature' => false,
];
if ($request->query('mode') === 'fullscreen') {

View File

@@ -134,6 +134,7 @@ class PrePaymentController extends Controller
'is_recurring' => $request->is_recurring == 'on' ? true : false,
'variables' => $variables = ($invitation && auth()->guard('contact')->user()->client->getSetting('show_accept_invoice_terms')) ? (new HtmlEngine($invitation))->generateLabelsAndValues() : false,
'docuninja_active' => false,
'requires_signature' => false,
];
return $this->render('invoices.payment', $data);

View File

@@ -55,7 +55,7 @@ class QuoteController extends Controller
{
/* If the quote is expired, convert the status here */
$invitation = $quote->invitations()->where('client_contact_id', auth()->guard('contact')->user()->id)->first();
$invitation = $quote->invitations()->where('client_contact_id', auth()->guard('contact')->user()->id)->first() ?? $quote->invitations()->first();
$variables = ($invitation && auth()->guard('contact')->user()->client->getSetting('show_accept_quote_terms')) ? (new HtmlEngine($invitation))->generateLabelsAndValues() : false;
$docuninja_active = $invitation->company->docuninjaActive();
$signature_accepted = $invitation->quote->sync?->dn_completed ?? false;

View File

@@ -20,36 +20,36 @@ use Illuminate\Validation\Validator;
class AddTaxIdentifierRequest extends FormRequest
{
public static array $vat_regex_patterns = [
'GB' => '/^GB(\d{9}|\d{12})$/', // Great Britain
'DE' => '/^DE\d{9}$/', // Germany
'AT' => '/^ATU\d{8}$/', // Austria
'BE' => '/^BE[0-1]\d{9}$/', // Belgium
'BG' => '/^BG\d{9,10}$/', // Bulgaria
'CY' => '/^CY\d{8}[A-Z]$/', // Cyprus
'HR' => '/^HR\d{11}$/', // Croatia
'DK' => '/^DK\d{8}$/', // Denmark
'ES' => '/^ES[A-Z0-9]\d{7}[A-Z0-9]$/', // Spain
'EE' => '/^EE\d{9}$/', // Estonia
'FI' => '/^FI\d{8}$/', // Finland
'FR' => '/^FR[A-Z0-9]{2}\d{9}$/', // France
'EL' => '/^EL\d{9}$/', // Greece
'AT' => '/^ATU\d{8}$/', // Austria
'BE' => '/^BE[01]\d{9}$/', // Belgium
'BG' => '/^BG\d{9,10}$/', // Bulgaria
'CY' => '/^CY\d{8}[A-Z]$/', // Cyprus
'CZ' => '/^CZ\d{8,10}$/', // Czech Republic
'DE' => '/^DE\d{9}$/', // Germany
'DK' => '/^DK\d{8}$/', // Denmark
'EE' => '/^EE\d{9}$/', // Estonia
'EL' => '/^EL\d{9}$/', // Greece
'ES' => '/^ES[A-Z0-9]\d{7}[A-Z0-9]$/', // Spain
'FI' => '/^FI\d{8}$/', // Finland
'FR' => '/^FR[A-HJ-NP-Z0-9]{2}\d{9}$/', // France
'GB' => '/^GB(\d{9}|\d{12})$/', // Great Britain
'HR' => '/^HR\d{11}$/', // Croatia
'HU' => '/^HU\d{8}$/', // Hungary
'IE' => '/^IE\d{7}[A-WYZ][A-Z]?$/', // Ireland
'IT' => '/^IT\d{11}$/', // Italy
'IS' => '/^IS\d{10}|IS[\dA-Z]{6}$/', // Iceland
'LV' => '/^LV\d{11}$/', // Latvia
'LT' => '/^LT(\d{9}|\d{12})$/', // Lithuania
'IE' => '/^IE\d[A-Z0-9+*]\d{5}[A-Z]{1,2}$/', // Ireland
'IS' => '/^IS(\d{10}|[\dA-Z]{6})$/', // Iceland
'IT' => '/^IT\d{11}$/', // Italy
'LT' => '/^LT(\d{9}|\d{12})$/', // Lithuania
'LU' => '/^LU\d{8}$/', // Luxembourg
'LV' => '/^LV\d{11}$/', // Latvia
'MT' => '/^MT\d{8}$/', // Malta
'NL' => '/^NL\d{9}B\d{2}$/', // Netherlands
'NO' => '/^NO\d{9}MVA$/', // Norway
'NL' => '/^NL\d{9}B\d{2}$/', // Netherlands
'NO' => '/^NO\d{9}MVA$/', // Norway
'PL' => '/^PL\d{10}$/', // Poland
'PT' => '/^PT\d{9}$/', // Portugal
'CZ' => '/^CZ\d{8,10}$/', // Czech Republic
'RO' => '/^RO\d{2,10}$/', // Romania
'SK' => '/^SK\d{10}$/', // Slovakia
'RO' => '/^RO\d{2,10}$/', // Romania
'SE' => '/^SE\d{12}$/', // Sweden
'SI' => '/^SI\d{8}$/', // Slovenia
'SE' => '/^SE\d{12}$/', // Sweden
'SK' => '/^SK\d{10}$/', // Slovakia
];
public function authorize(): bool

View File

@@ -22,6 +22,7 @@ class BlackListRule implements ValidationRule
{
/** Bad domains +/- disposable email domains */
private array $blacklist = [
"fxzig.com",
"dollicons.com",
"mypost.lol",
"mozmail.com",

View File

@@ -65,14 +65,13 @@ class SubscriptionCron
private function timezoneAware()
{
Invoice::query()
Invoice::query()
->with('company')
->where('is_deleted', 0)
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
->where('balance', '>', 0)
->whereBetween('due_date', [now()->subMonth()->startOfDay(),now()->addDay()->startOfDay()])
->where('is_deleted', 0)
->where('is_proforma', 0)
->whereDate('due_date', '<=', now()->addDay()->startOfDay())
->whereNull('deleted_at')
->where('balance', '>', 0)
->whereNotNull('subscription_id')
->groupBy('company_id')
->cursor()
@@ -88,14 +87,14 @@ class SubscriptionCron
if ($timezone_now->gt($timezone_now->copy()->startOfDay()) && $timezone_now->lt($timezone_now->copy()->startOfDay()->addMinutes(30))) {
Invoice::query()
->with('subscription','client')
->where('company_id', $company->id)
->whereNull('deleted_at')
->where('is_deleted', 0)
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
->whereBetween('due_date', [now()->subMonth()->startOfDay(),now()->addDay()->startOfDay()])
->where('is_deleted', 0)
->where('is_proforma', 0)
->whereNotNull('subscription_id')
->where('balance', '>', 0)
->whereDate('due_date', '<=', now()->addDay()->startOfDay())
->cursor()
->each(function (Invoice $invoice) {

View File

@@ -101,6 +101,26 @@ class CreateRawPdf
public function handle()
{
/** Serve DocuNinja signed PDF if signing is complete */
if (in_array($this->entity_string, ['invoice', 'quote', 'purchase_order'])
&& $this->company->docuninjaActive()
&& $this->entity->sync?->dn_completed
) {
$document = $this->entity->getSignedPdfDocument();
if ($document) {
try {
$pdf = $document->getFile();
if ($pdf && strlen($pdf) > 0) {
return $pdf;
}
} catch (\Exception $e) {
nlog("Failed to retrieve signed PDF for {$this->entity_string} {$this->entity->id}: " . $e->getMessage());
}
}
}
$pdf = $this->generatePdf();
if ($this->isBlankPdf($pdf)) {

View File

@@ -87,15 +87,15 @@ class CreateUbl implements ShouldQueue
$taxtotal = new TaxTotal();
$taxAmount1 = $taxAmount2 = $taxAmount3 = 0;
if (strlen($invoice->tax_name1) > 1) {
if (strlen($invoice->tax_name1 ?? '') > 1) {
$taxAmount1 = $this->createTaxRate($taxtotal, $taxable, $invoice->tax_rate1, $invoice->tax_name1);
}
if (strlen($invoice->tax_name2) > 1) {
if (strlen($invoice->tax_name2 ?? '') > 1) {
$taxAmount2 = $this->createTaxRate($taxtotal, $taxable, $invoice->tax_rate2, $invoice->tax_name2);
}
if (strlen($invoice->tax_name3) > 1) {
if (strlen($invoice->tax_name3 ?? '') > 1) {
$taxAmount3 = $this->createTaxRate($taxtotal, $taxable, $invoice->tax_rate3, $invoice->tax_name3);
}
@@ -155,15 +155,15 @@ class CreateUbl implements ShouldQueue
$taxtotal = new TaxTotal();
$itemTaxAmount1 = $itemTaxAmount2 = $itemTaxAmount3 = 0;
if (strlen($item->tax_name1) > 1) {
if (strlen($item->tax_name1 ?? '') > 1) {
$itemTaxAmount1 = $this->createTaxRate($taxtotal, $taxable, $item->tax_rate1, $item->tax_name1);
}
if (strlen($item->tax_name2) > 1) {
if (strlen($item->tax_name2 ?? '') > 1) {
$itemTaxAmount2 = $this->createTaxRate($taxtotal, $taxable, $item->tax_rate2, $item->tax_name2);
}
if (strlen($item->tax_name3) > 1) {
if (strlen($item->tax_name3 ?? '') > 1) {
$itemTaxAmount3 = $this->createTaxRate($taxtotal, $taxable, $item->tax_rate3, $item->tax_name3);
}

View File

@@ -69,58 +69,81 @@ class MailWebhookSync implements ShouldQueue
private function scanSentEmails()
{
$invitationTypes = [
\App\Models\InvoiceInvitation::class,
\App\Models\QuoteInvitation::class,
\App\Models\RecurringInvoiceInvitation::class,
\App\Models\CreditInvitation::class,
\App\Models\PurchaseOrderInvitation::class,
];
$query = \App\Models\InvoiceInvitation::whereNotNull('message_id')
->whereNull('email_status')
->whereHas('company', function ($q) {
$q->where('settings->email_sending_method', 'default');
});
foreach ($invitationTypes as $model) {
$this->runIterator($query);
$query = \App\Models\QuoteInvitation::whereNotNull('message_id')
->whereNull('email_status')
->whereHas('company', function ($q) {
$q->where('settings->email_sending_method', 'default');
});
$this->runIterator($query);
$query = \App\Models\RecurringInvoiceInvitation::whereNotNull('message_id')
->whereNull('email_status')
->whereHas('company', function ($q) {
$q->where('settings->email_sending_method', 'default');
});
$this->runIterator($query);
$query = \App\Models\CreditInvitation::whereNotNull('message_id')
->whereNull('email_status')
->whereHas('company', function ($q) {
$q->where('settings->email_sending_method', 'default');
});
$this->runIterator($query);
$query = \App\Models\PurchaseOrderInvitation::whereNotNull('message_id')
->whereNull('email_status')
->whereHas('company', function ($q) {
$q->where('settings->email_sending_method', 'default');
});
$this->runIterator($query);
$query = $model::whereBetween('created_at', [now()->subHours(12), now()->subHour()])
->whereNotNull('message_id')
->whereNull('email_status')
->whereHas('company', function ($q) {
$q->where('settings->email_sending_method', 'default');
});
$this->runIterator($query);
}
}
// private function scanSentEmails()
// {
// $query = \App\Models\InvoiceInvitation::whereNotNull('message_id')
// ->whereNull('email_status')
// ->whereHas('company', function ($q) {
// $q->where('settings->email_sending_method', 'default');
// });
// $this->runIterator($query);
// $query = \App\Models\QuoteInvitation::whereNotNull('message_id')
// ->whereNull('email_status')
// ->whereHas('company', function ($q) {
// $q->where('settings->email_sending_method', 'default');
// });
// $this->runIterator($query);
// $query = \App\Models\RecurringInvoiceInvitation::whereNotNull('message_id')
// ->whereNull('email_status')
// ->whereHas('company', function ($q) {
// $q->where('settings->email_sending_method', 'default');
// });
// $this->runIterator($query);
// $query = \App\Models\CreditInvitation::whereNotNull('message_id')
// ->whereNull('email_status')
// ->whereHas('company', function ($q) {
// $q->where('settings->email_sending_method', 'default');
// });
// $this->runIterator($query);
// $query = \App\Models\PurchaseOrderInvitation::whereNotNull('message_id')
// ->whereNull('email_status')
// ->whereHas('company', function ($q) {
// $q->where('settings->email_sending_method', 'default');
// });
// $this->runIterator($query);
// }
private function runIterator($query)
{
$query->whereBetween('created_at', [now()->subHours(12), now()->subHour()])
->orderBy('id', 'desc')
->each(function ($invite) {
// $query->whereBetween('created_at', [now()->subHours(12), now()->subHour()])
// ->orderBy('id', 'desc')
$query->each(function ($invite) {
$token = config('services.postmark.token');
$postmark = new \Postmark\PostmarkClient($token);

View File

@@ -49,7 +49,7 @@ class CleanStaleInvoiceOrder implements ShouldQueue
->withTrashed()
->where('status_id', Invoice::STATUS_SENT)
->where('is_proforma', 1)
->where('created_at', '<', now()->subHour())
->whereBetween('created_at', [now()->subDay(), now()->subHour()])
->cursor()
->each(function ($invoice) use ($repo) {
$invoice->is_proforma = false;
@@ -59,8 +59,8 @@ class CleanStaleInvoiceOrder implements ShouldQueue
Invoice::query()
->withTrashed()
->whereBetween('updated_at', [now()->subDay(), now()->subHour()])
->where('status_id', Invoice::STATUS_SENT)
->where('updated_at', '<', now()->subHour())
->where('balance', '>', 0)
->whereJsonContains('line_items', ['type_id' => '3'])
->cursor()
@@ -71,7 +71,7 @@ class CleanStaleInvoiceOrder implements ShouldQueue
Invoice::query()
->withTrashed()
->whereIn('status_id', [Invoice::STATUS_PARTIAL, Invoice::STATUS_PAID])
->where('updated_at', '<', now()->subHour())
->whereBetween('updated_at', [now()->subDay(), now()->subHour()])
->whereJsonContains('line_items', ['type_id' => '3'])
->cursor()
->each(function ($invoice) {
@@ -105,7 +105,7 @@ class CleanStaleInvoiceOrder implements ShouldQueue
->withTrashed()
->where('status_id', Invoice::STATUS_SENT)
->where('is_proforma', 1)
->where('created_at', '<', now()->subHour())
->whereBetween('created_at', [now()->subDay(), now()->subHour()])
->cursor()
->each(function ($invoice) use ($repo) {
$invoice->is_proforma = false;
@@ -116,7 +116,7 @@ class CleanStaleInvoiceOrder implements ShouldQueue
Invoice::query()
->withTrashed()
->where('status_id', Invoice::STATUS_SENT)
->where('updated_at', '<', now()->subHour())
->whereBetween('updated_at', [now()->subDay(), now()->subHour()])
->where('balance', '>', 0)
->whereJsonContains('line_items', ['type_id' => '3'])
->cursor()
@@ -127,7 +127,7 @@ class CleanStaleInvoiceOrder implements ShouldQueue
Invoice::query()
->withTrashed()
->whereIn('status_id', [Invoice::STATUS_PARTIAL, Invoice::STATUS_PAID])
->where('updated_at', '<', now()->subHour())
->whereBetween('updated_at', [now()->subDay(), now()->subHour()])
->whereJsonContains('line_items', ['type_id' => '3'])
->cursor()
->each(function ($invoice) {

View File

@@ -62,7 +62,7 @@ class ReminderJob implements ShouldQueue
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
->whereNull('deleted_at')
->where('balance', '>', 0)
->where('next_send_date', '<=', now()->toDateTimeString())
->whereBetween('next_send_date', [now()->subMonth()->startOfDay(), now()->addDay()->startOfDay()])
->whereHas('client', function ($query) {
$query->where('is_deleted', 0)
->where('deleted_at', null);
@@ -88,7 +88,7 @@ class ReminderJob implements ShouldQueue
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
->whereNull('deleted_at')
->where('balance', '>', 0)
->where('next_send_date', '<=', now()->toDateTimeString())
->whereBetween('next_send_date', [now()->subMonth()->startOfDay(), now()->addDay()->startOfDay()])
->whereHas('client', function ($query) {
$query->where('is_deleted', 0)
->where('deleted_at', null);

View File

@@ -0,0 +1,55 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Listeners\User;
use App\Libraries\MultiDB;
use App\Models\Activity;
use App\Repositories\ActivityRepository;
use Illuminate\Contracts\Queue\ShouldQueue;
use stdClass;
class PurgedUserActivity implements ShouldQueue
{
protected $activityRepo;
/**
* Create the event listener.
*
* @param ActivityRepository $activityRepo
*/
public function __construct(ActivityRepository $activityRepo)
{
$this->activityRepo = $activityRepo;
}
/**
* Handle the event.
*
* @param object $event
* @return void
*/
public function handle($event)
{
MultiDB::setDb($event->company->db);
$fields = new stdClass();
$fields->user_id = $event->admin_user->id;
$fields->company_id = $event->company->id;
$fields->activity_type_id = Activity::PURGE_USER;
$fields->account_id = $event->company->account_id;
$fields->notes = $event->purged_user_name;
$this->activityRepo->save($fields, $event->admin_user, $event->event_vars);
}
}

View File

@@ -5,6 +5,8 @@ namespace App\Livewire\Flow2;
use Livewire\Component;
use App\Libraries\MultiDB;
use App\DataMapper\InvoiceSync;
use App\DataMapper\PurchaseOrderSync;
use App\DataMapper\QuoteSync;
use App\Models\QuoteInvitation;
use App\Models\CreditInvitation;
use App\Models\InvoiceInvitation;
@@ -91,7 +93,11 @@ class DocuNinjaLoader extends Component
$signable = $invitation->{$this->entity_type}->service()->getDocuNinjaSignable($invitation);
$sync = new InvoiceSync(qb_id: '', dn_completed: false);
$sync = match($this->entity_type) {
'quote' => new QuoteSync(qb_id: '', dn_completed: false),
'purchase_order' => new PurchaseOrderSync(qb_id: '', dn_completed: false),
default => new InvoiceSync(qb_id: '', dn_completed: false),
};
$sync->addInvitation(
$signable['invitation_key'],
$signable['document_id'],

View File

@@ -312,6 +312,8 @@ class Activity extends StaticModel
public const QUICKBOOKS_PUSH_SUCCESS = 165;
public const PURGE_USER = 166;
protected $casts = [
'is_system' => 'boolean',
'updated_at' => 'timestamp',

View File

@@ -395,6 +395,26 @@ class BaseModel extends Model
return \App\Services\Pdf\Purify::clean(html_entity_decode($parsed));
}
/**
* Retrieve the DocuNinja signed PDF document for this entity.
*
* Uses the dn_document_hashed_id stored on the entity's sync object
* to look up the Document record.
*
* @return \App\Models\Document|null
*/
public function getSignedPdfDocument(): ?\App\Models\Document
{
/** @var \App\Models\Invoice | \App\Models\Credit | \App\Models\Quote | \App\Models\PurchaseOrder $this */
if (!$this->sync?->dn_document_hashed_id) {
return null;
}
return $this->documents()
->where('id', $this->decodePrimaryKey($this->sync->dn_document_hashed_id))
->first();
}
/**
* Merged PDFs associated with the entity / company
* into a single document

View File

@@ -236,7 +236,7 @@ class Credit extends BaseModel
'name' => ctrans('texts.credit') . " " . $this->number . " | " . $this->client->present()->name() . ' | ' . Number::formatMoney($this->amount, $this->company) . ' | ' . $this->translateDate($this->date, $this->company->date_format(), $locale),
'hashed_id' => $this->hashed_id,
'number' => (string) $this->number,
'is_deleted' => (bool) $this->is_deleted,
'is_deleted' => $this->is_deleted,
'amount' => (float) $this->amount,
'balance' => (float) $this->balance,
'due_date' => $this->due_date,

View File

@@ -15,7 +15,7 @@ namespace App\Models;
use App\Utils\Ninja;
use App\Utils\Number;
use Illuminate\Support\Carbon;
use App\DataMapper\InvoiceSync;
use App\DataMapper\PurchaseOrderSync;
use App\Helpers\Invoice\InvoiceSum;
use Illuminate\Support\Facades\App;
use Elastic\ScoutDriverPlus\Searchable;
@@ -113,7 +113,7 @@ use App\Events\PurchaseOrder\PurchaseOrderWasEmailed;
* @property object|null $tax_data
* @property object|null $e_invoice
* @property int|null $location_id
* @property \App\DataMapper\InvoiceSync|null $sync
* @property \App\DataMapper\PurchaseOrderSync|null $sync
* @method static \Illuminate\Database\Eloquent\Builder|PurchaseOrder exclude($columns)
* @method static \Database\Factories\PurchaseOrderFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder|PurchaseOrder filter(\App\Filters\QueryFilters $filters)
@@ -218,7 +218,7 @@ class PurchaseOrder extends BaseModel
'deleted_at' => 'timestamp',
'is_amount_discount' => 'bool',
'e_invoice' => 'object',
'sync' => InvoiceSync::class,
'sync' => PurchaseOrderSync::class,
];
public const STATUS_DRAFT = 1;

View File

@@ -1,101 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2026. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\PaymentDrivers\Stripe;
use App\Http\Requests\ClientPortal\Payments\PaymentResponseRequest;
use App\Models\GatewayType;
use App\Models\SystemLog;
use App\PaymentDrivers\StripePaymentDriver;
use App\Utils\Ninja;
class ApplePay
{
/** @var StripePaymentDriver */
public $stripe_driver;
public function __construct(StripePaymentDriver $stripe_driver)
{
$this->stripe_driver = $stripe_driver;
}
public function paymentView(array $data)
{
$this->registerDomain();
$data['gateway'] = $this->stripe_driver;
$data['payment_hash'] = $this->stripe_driver->payment_hash->hash;
$data['payment_method_id'] = GatewayType::APPLE_PAY;
$data['country'] = $this->stripe_driver->client->country;
$data['currency'] = $this->stripe_driver->client->currency()->code;
$data['stripe_amount'] = $this->stripe_driver->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe_driver->client->currency()->precision, $this->stripe_driver->client->currency());
$data['invoices'] = $this->stripe_driver->payment_hash->invoices();
$data['intent'] = \Stripe\PaymentIntent::create([
'amount' => $data['stripe_amount'],
'currency' => $this->stripe_driver->client->getCurrencyCode(),
'metadata' => [
'payment_hash' => $this->stripe_driver->payment_hash->hash,
'gateway_type_id' => GatewayType::APPLE_PAY,
],
], $this->stripe_driver->stripe_connect_auth);
$this->stripe_driver->payment_hash->data = array_merge((array) $this->stripe_driver->payment_hash->data, ['stripe_amount' => $data['stripe_amount']]);
$this->stripe_driver->payment_hash->save();
return render('gateways.stripe.applepay.pay', $data);
}
public function paymentResponse(PaymentResponseRequest $request)
{
$this->stripe_driver->init();
$state = [
'server_response' => json_decode($request->gateway_response),
'payment_hash' => $request->payment_hash,
];
$state['payment_intent'] = \Stripe\PaymentIntent::retrieve($state['server_response']->id, $this->stripe_driver->stripe_connect_auth);
$state['customer'] = $state['payment_intent']->customer;
$this->stripe_driver->payment_hash->data = array_merge((array) $this->stripe_driver->payment_hash->data, $state);
$this->stripe_driver->payment_hash->save();
$server_response = $this->stripe_driver->payment_hash->data->server_response;
$response_handler = new CreditCard($this->stripe_driver);
if ($server_response->status == 'succeeded') {
$this->stripe_driver->logSuccessfulGatewayResponse(['response' => json_decode($request->gateway_response), 'data' => $this->stripe_driver->payment_hash->data], SystemLog::TYPE_STRIPE);
return $response_handler->processSuccessfulPayment();
}
return $response_handler->processUnsuccessfulPayment($server_response);
}
private function registerDomain()
{
if (Ninja::isHosted()) {
$domain = $this->stripe_driver->company_gateway->company->portal_domain ?? $this->stripe_driver->company_gateway->company->domain();
\Stripe\ApplePayDomain::create([
'domain_name' => $domain,
], $this->stripe_driver->stripe_connect_auth);
} else {
\Stripe\ApplePayDomain::create([
'domain_name' => config('ninja.app_url'),
]);
}
}
}

View File

@@ -40,6 +40,7 @@ use App\Events\User\UserLoggedIn;
use App\Observers\ClientObserver;
use App\Observers\CreditObserver;
use App\Observers\VendorObserver;
use App\Events\User\UserWasPurged;
use App\Observers\AccountObserver;
use App\Observers\CompanyObserver;
use App\Observers\ExpenseObserver;
@@ -132,6 +133,7 @@ use App\Events\Invoice\InvoiceWasReversed;
use App\Events\Payment\PaymentWasArchived;
use App\Events\Payment\PaymentWasRefunded;
use App\Events\Payment\PaymentWasRestored;
use App\Listeners\User\PurgedUserActivity;
use Illuminate\Mail\Events\MessageSending;
use App\Events\Document\DocumentWasCreated;
use App\Events\Document\DocumentWasDeleted;
@@ -355,6 +357,9 @@ class EventServiceProvider extends ServiceProvider
UserWasRestored::class => [
RestoredUserActivity::class,
],
UserWasPurged::class => [
PurgedUserActivity::class,
],
ContactLoggedIn::class => [
UpdateContactLastLogin::class,
],

View File

@@ -322,6 +322,12 @@ class BaseRepository
if ($model->status_id != Invoice::STATUS_DRAFT) {
$model->service()->updateStatus()->save();
// $model->client->service()->calculateBalance($model); //2026-02-21 - disabled due to race conditions
$adjustment = round($state['finished_amount'] - $state['starting_amount'], 2);
if ($adjustment != 0) {
$model->client->service()->updateBalance($adjustment);
}
}
if (!$model->design_id) {

View File

@@ -31,6 +31,7 @@ use App\Models\RecurringQuote;
use App\Utils\Traits\MakesHash;
use App\Models\RecurringExpense;
use App\Models\RecurringInvoice;
use App\Events\User\UserWasPurged;
use Illuminate\Support\Facades\DB;
use App\DataMapper\CompanySettings;
use App\Events\User\UserWasDeleted;
@@ -147,7 +148,7 @@ class UserRepository extends BaseRepository
}
if (array_key_exists('company_user', $data)) {
$this->forced_includes = 'company_users';
// $this->forced_includes = 'company_users'; //2026-02-23 - not needed @deprecate and remove in 5.13.0
$company = auth()->user()->company();
@@ -270,8 +271,13 @@ class UserRepository extends BaseRepository
public function purge(User $user, User $new_owner_user): void
{
$notes = $user->present()->name();
\DB::transaction(function () use ($user, $new_owner_user) {
/* Remove all of the system tokens for the user */
$user->tokens()->where('is_system', true)->forceDelete();
// Relations to transfer user_id to new owner
$allRelations = [
'activities', 'bank_integrations', 'bank_transaction_rules',
@@ -309,8 +315,35 @@ class UserRepository extends BaseRepository
->update(['assigned_user_id' => null]);
}
// Fix scheduler parameters JSON — embedded user_id
$user->schedules()->each(function ($scheduler) use ($new_owner_user, $user) {
$params = $scheduler->parameters;
if (isset($params['user_id']) && $params['user_id'] == $user->id) {
$params['user_id'] = $new_owner_user->id;
$scheduler->parameters = $params;
$scheduler->save();
}
});
// Fix gmail_sending_user_id in company settings
$old_hashed_id = $user->hashed_id;
$new_owner_user->account->companies()->cursor()->each(function ($company) use ($old_hashed_id) {
$settings = $company->settings;
if (isset($settings->gmail_sending_user_id) && $settings->gmail_sending_user_id === $old_hashed_id) {
$settings->gmail_sending_user_id = '0';
$settings->email_sending_method = 'default';
$company->settings = $settings;
$company->save();
}
});
$user->forceDelete();
});
$company = $new_owner_user->account->default_company ?? $new_owner_user->companies->first();
event(new UserWasPurged($new_owner_user, $notes, $company, Ninja::eventVars(auth()->user() ? auth()->user()->id : null)));
}
}

View File

@@ -363,6 +363,9 @@ class ProcessBankRules extends AbstractService
private function matchNumberOperator($bt_value, $rule_value, $operator): bool
{
$bt_value = (float) $bt_value;
$rule_value = (float) $rule_value;
return match ($operator) {
'>' => round($bt_value - $rule_value, 2) > 0,
'>=' => round($bt_value - $rule_value, 2) >= 0,

View File

@@ -270,6 +270,7 @@ class StorecoveExpense
$countries = app('countries');
$country_id = $countries->first(function ($c) use ($party) {
/** @var \App\Models\Country $c */
return $party->getAddress()->getCountry() == $c->iso_3166_2 || $party->getAddress()->getCountry() == $c->iso_3166_3;
})->id ?? 1;

View File

@@ -1197,7 +1197,7 @@ class Peppol extends AbstractService
$price = new Price();
$pa = new PriceAmount();
$pa->currencyID = $this->invoice->client->currency()->code;
$pa->amount = (string) $item->cost;
$pa->amount = $this->invoice->uses_inclusive_taxes ? (string) $item->net_cost :(string) $item->cost;
$price->PriceAmount = $pa;
$line->Price = $price;
}

View File

@@ -84,8 +84,8 @@ class EntityLevel implements EntityLevelInterface
* Patterns allow optional country prefix (e.g., "AT" or "ATU12345678").
*/
private array $vat_number_regex = [
'AT' => '/^(AT)?U\d{9}$/i', // Austria: U + 9 digits
'BE' => '/^(BE)?0\d{9}$/i', // Belgium: 0 + 9 digits
'AT' => '/^(AT)?U\d{8}$/i', // Austria: U + 8 digits
'BE' => '/^(BE)?[01]\d{9}$/i', // Belgium: 0 or 1 + 9 digits
'BG' => '/^(BG)?\d{9,10}$/i', // Bulgaria: 9-10 digits
'CY' => '/^(CY)?\d{8}[A-Z]$/i', // Cyprus: 8 digits + 1 letter
'CZ' => '/^(CZ)?\d{8,10}$/i', // Czech Republic: 8-10 digits

View File

@@ -223,10 +223,6 @@
{
"type": "vcs",
"url": "https://github.com/turbo124/snappdf"
},
{
"type": "path",
"url": "../admin-api"
}
],
"minimum-stability": "dev",

60
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "d044985779d84769736a51fd6217b050",
"content-hash": "1b74189cd9dbfc430f4a65c9c3e0f784",
"packages": [
{
"name": "afosto/yaac",
@@ -497,16 +497,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.370.0",
"version": "3.370.1",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "3cfb2c787c43efa546ba3b6b596956cbef5cdd53"
"reference": "273a9bbed9e73016be390b8428f7925f15ea053e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/3cfb2c787c43efa546ba3b6b596956cbef5cdd53",
"reference": "3cfb2c787c43efa546ba3b6b596956cbef5cdd53",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/273a9bbed9e73016be390b8428f7925f15ea053e",
"reference": "273a9bbed9e73016be390b8428f7925f15ea053e",
"shasum": ""
},
"require": {
@@ -588,9 +588,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.370.0"
"source": "https://github.com/aws/aws-sdk-php/tree/3.370.1"
},
"time": "2026-02-20T19:09:33+00:00"
"time": "2026-02-23T19:05:30+00:00"
},
{
"name": "babenkoivan/elastic-adapter",
@@ -3036,16 +3036,16 @@
},
{
"name": "google/apiclient-services",
"version": "v0.433.0",
"version": "v0.434.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/google-api-php-client-services.git",
"reference": "feb9c13e54457a6f5e157b7eae78b41566df43b5"
"reference": "65550d5fd5c468badd75db9ec73c2c187470a00d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/feb9c13e54457a6f5e157b7eae78b41566df43b5",
"reference": "feb9c13e54457a6f5e157b7eae78b41566df43b5",
"url": "https://api.github.com/repos/googleapis/google-api-php-client-services/zipball/65550d5fd5c468badd75db9ec73c2c187470a00d",
"reference": "65550d5fd5c468badd75db9ec73c2c187470a00d",
"shasum": ""
},
"require": {
@@ -3074,9 +3074,9 @@
],
"support": {
"issues": "https://github.com/googleapis/google-api-php-client-services/issues",
"source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.433.0"
"source": "https://github.com/googleapis/google-api-php-client-services/tree/v0.434.0"
},
"time": "2026-02-16T01:42:26+00:00"
"time": "2026-02-18T01:00:35+00:00"
},
{
"name": "google/auth",
@@ -8012,16 +8012,16 @@
},
{
"name": "nette/schema",
"version": "v1.3.4",
"version": "v1.3.5",
"source": {
"type": "git",
"url": "https://github.com/nette/schema.git",
"reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7"
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/086497a2f34b82fede9b5a41cc8e131d087cd8f7",
"reference": "086497a2f34b82fede9b5a41cc8e131d087cd8f7",
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
"shasum": ""
},
"require": {
@@ -8029,8 +8029,10 @@
"php": "8.1 - 8.5"
},
"require-dev": {
"nette/phpstan-rules": "^1.0",
"nette/tester": "^2.6",
"phpstan/phpstan": "^2.0@stable",
"phpstan/extension-installer": "^1.4@stable",
"phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8"
},
"type": "library",
@@ -8071,9 +8073,9 @@
],
"support": {
"issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.4"
"source": "https://github.com/nette/schema/tree/v1.3.5"
},
"time": "2026-02-08T02:54:00+00:00"
"time": "2026-02-23T03:47:12+00:00"
},
{
"name": "nette/utils",
@@ -21697,23 +21699,23 @@
},
{
"name": "spatie/laravel-ignition",
"version": "2.10.0",
"version": "2.11.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-ignition.git",
"reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5"
"reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/2abefdcca6074a9155f90b4ccb3345af8889d5f5",
"reference": "2abefdcca6074a9155f90b4ccb3345af8889d5f5",
"url": "https://api.github.com/repos/spatie/laravel-ignition/zipball/11f38d1ff7abc583a61c96bf3c1b03610a69cccd",
"reference": "11f38d1ff7abc583a61c96bf3c1b03610a69cccd",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/support": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0|^13.0",
"nesbot/carbon": "^2.72|^3.0",
"php": "^8.2",
"spatie/ignition": "^1.15.1",
@@ -21721,10 +21723,10 @@
"symfony/var-dumper": "^7.4|^8.0"
},
"require-dev": {
"livewire/livewire": "^3.7.0|^4.0",
"livewire/livewire": "^3.7.0|^4.0|dev-josh/v3-laravel-13-support",
"mockery/mockery": "^1.6.12",
"openai-php/client": "^0.10.3",
"orchestra/testbench": "^v9.16.0|^10.6",
"openai-php/client": "^0.10.3|^0.19",
"orchestra/testbench": "^v9.16.0|^10.6|^11.0",
"pestphp/pest": "^3.7|^4.0",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan-deprecation-rules": "^2.0.3",
@@ -21785,7 +21787,7 @@
"type": "github"
}
],
"time": "2026-01-20T13:16:11+00:00"
"time": "2026-02-22T19:14:05+00:00"
},
{
"name": "spaze/phpstan-stripe",

View File

@@ -17,8 +17,8 @@ return [
'require_https' => env('REQUIRE_HTTPS', true),
'app_url' => rtrim(env('APP_URL', ''), '/'),
'app_domain' => env('APP_DOMAIN', 'invoicing.co'),
'app_version' => env('APP_VERSION', '5.12.65'),
'app_tag' => env('APP_TAG', '5.12.65'),
'app_version' => env('APP_VERSION', '5.12.66'),
'app_tag' => env('APP_TAG', '5.12.66'),
'minimum_client_version' => '5.0.16',
'terms_version' => '1.0.1',
'api_secret' => env('API_SECRET', false),

View File

@@ -5913,6 +5913,8 @@ $lang = array(
'payment_type_FPX' => 'FPX',
'payment_type_Stripe Bank Transfer' => 'Stripe Bank Transfer',
'activity_164' => 'QuickBooks sync failed. :notes',
'activity_166' => 'User :notes was purged by :user',
'purged_user' => 'Successfully purged user',
);
return $lang;

42
package-lock.json generated
View File

@@ -7,7 +7,7 @@
"name": "@invoiceninja/invoiceninja",
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.1",
"@docuninja/builder2.0": "^0.0.76",
"@docuninja/builder2.0": "^0.0.85",
"axios": "^0.25",
"card-js": "^1.0.13",
"card-validator": "^8.1.1",
@@ -1824,9 +1824,9 @@
}
},
"node_modules/@docuninja/builder2.0": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@docuninja/builder2.0/-/builder2.0-0.0.76.tgz",
"integrity": "sha512-Lpe75w/ZPJKGStM/SPx2+C4xt/IL+etb1xBgFpE5jmLwZyToRrUBHZFl8q4Uwx7nItA4RYTqyWRamAL85eorFQ==",
"version": "0.0.85",
"resolved": "https://registry.npmjs.org/@docuninja/builder2.0/-/builder2.0-0.0.85.tgz",
"integrity": "sha512-BuoYKWUEV6A1gwDg8HEAIu2SxSobFPuJhPoHn8g8iHGo607PpYPSNslYXgk4AaSQSl8Q3V1rVoPHejLorCrbyw==",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -2275,14 +2275,14 @@
"peer": true
},
"node_modules/@docuninja/builder2.0/node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~7.18.0"
}
},
"node_modules/@docuninja/builder2.0/node_modules/axios": {
@@ -14969,9 +14969,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"license": "MIT",
"optional": true,
"peer": true
@@ -17311,9 +17311,9 @@
}
},
"@docuninja/builder2.0": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@docuninja/builder2.0/-/builder2.0-0.0.76.tgz",
"integrity": "sha512-Lpe75w/ZPJKGStM/SPx2+C4xt/IL+etb1xBgFpE5jmLwZyToRrUBHZFl8q4Uwx7nItA4RYTqyWRamAL85eorFQ==",
"version": "0.0.85",
"resolved": "https://registry.npmjs.org/@docuninja/builder2.0/-/builder2.0-0.0.85.tgz",
"integrity": "sha512-BuoYKWUEV6A1gwDg8HEAIu2SxSobFPuJhPoHn8g8iHGo607PpYPSNslYXgk4AaSQSl8Q3V1rVoPHejLorCrbyw==",
"requires": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
@@ -17533,13 +17533,13 @@
"peer": true
},
"@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"version": "25.3.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.0.tgz",
"integrity": "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==",
"optional": true,
"peer": true,
"requires": {
"undici-types": "~7.16.0"
"undici-types": "~7.18.0"
}
},
"axios": {
@@ -25918,9 +25918,9 @@
}
},
"undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"optional": true,
"peer": true
},

View File

@@ -25,7 +25,7 @@
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.1",
"@docuninja/builder2.0": "^0.0.76",
"@docuninja/builder2.0": "^0.0.85",
"axios": "^0.25",
"card-js": "^1.0.13",
"card-validator": "^8.1.1",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
import{i as o,w as i}from"./wait-8f4ae121.js";/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/class m{constructor(){var e;this.clientSecret=(e=document.querySelector("meta[name=stripe-pi-client-secret]"))==null?void 0:e.content}init(){var t,n;let e={};return document.querySelector("meta[name=stripe-account-id]")&&(e.apiVersion="2020-08-27",e.stripeAccount=(t=document.querySelector("meta[name=stripe-account-id]"))==null?void 0:t.content),this.stripe=Stripe((n=document.querySelector("meta[name=stripe-publishable-key]"))==null?void 0:n.content,e),this.elements=this.stripe.elements(),this}createPaymentRequest(){try{this.paymentRequest=this.stripe.paymentRequest(JSON.parse(document.querySelector("meta[name=payment-request-data").content))}catch(e){throw document.querySelector("#errors").innerText=e.message,document.querySelector("#errors").hidden=!1,e}return this}createPaymentRequestButton(){this.paymentRequestButton=this.elements.create("paymentRequestButton",{paymentRequest:this.paymentRequest})}handlePaymentRequestEvents(e,t){document.querySelector("#errors").hidden=!0,this.paymentRequest.on("paymentmethod",function(n){e.confirmCardPayment(t,{payment_method:n.paymentMethod.id},{handleActions:!1}).then(function(r){r.error?(document.querySelector("#errors").innerText=r.error.message,document.querySelector("#errors").hidden=!1,n.complete("fail")):(n.complete("success"),r.paymentIntent.status==="requires_action"?e.confirmCardPayment(t).then(function(s){s.error?(n.complete("fail"),document.querySelector("#errors").innerText=s.error.message,document.querySelector("#errors").hidden=!1):(document.querySelector('input[name="gateway_response"]').value=JSON.stringify(s.paymentIntent),document.getElementById("server-response").submit())}):(document.querySelector('input[name="gateway_response"]').value=JSON.stringify(r.paymentIntent),document.getElementById("server-response").submit()))})})}handle(){try{this.init().createPaymentRequest().createPaymentRequestButton()}catch{return}this.paymentRequest.canMakePayment().then(e=>{var t;if(e)return this.paymentRequestButton.mount("#payment-request-button");document.querySelector("#errors").innerHTML=JSON.parse((t=document.querySelector("meta[name=no-available-methods]"))==null?void 0:t.content),document.querySelector("#errors").hidden=!1}),this.handlePaymentRequestEvents(this.stripe,this.clientSecret)}}function a(){new m().handle()}o()?a():i("#stripe-browserpay-payment").then(()=>a());

View File

@@ -1,9 +0,0 @@
import{i as o,w as i}from"./wait-8f4ae121.js";/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/class m{constructor(){var e;this.clientSecret=(e=document.querySelector("meta[name=stripe-pi-client-secret]"))==null?void 0:e.content}init(){var t,n;let e={};return document.querySelector("meta[name=stripe-account-id]")&&(e.apiVersion="2020-08-27",e.stripeAccount=(t=document.querySelector("meta[name=stripe-account-id]"))==null?void 0:t.content),this.stripe=Stripe((n=document.querySelector("meta[name=stripe-publishable-key]"))==null?void 0:n.content,e),this.elements=this.stripe.elements(),this}createPaymentRequest(){return this.paymentRequest=this.stripe.paymentRequest(JSON.parse(document.querySelector("meta[name=payment-request-data").content)),this}createPaymentRequestButton(){this.paymentRequestButton=this.elements.create("paymentRequestButton",{paymentRequest:this.paymentRequest})}handlePaymentRequestEvents(e,t){document.querySelector("#errors").hidden=!0,this.paymentRequest.on("paymentmethod",function(n){e.confirmCardPayment(t,{payment_method:n.paymentMethod.id},{handleActions:!1}).then(function(r){r.error?(document.querySelector("#errors").innerText=r.error.message,document.querySelector("#errors").hidden=!1,n.complete("fail")):(n.complete("success"),r.paymentIntent.status==="requires_action"?e.confirmCardPayment(t).then(function(s){s.error?(n.complete("fail"),document.querySelector("#errors").innerText=s.error.message,document.querySelector("#errors").hidden=!1):(document.querySelector('input[name="gateway_response"]').value=JSON.stringify(s.paymentIntent),document.getElementById("server-response").submit())}):(document.querySelector('input[name="gateway_response"]').value=JSON.stringify(r.paymentIntent),document.getElementById("server-response").submit()))})})}handle(){this.init().createPaymentRequest().createPaymentRequestButton(),this.paymentRequest.canMakePayment().then(e=>{var t;if(e)return this.paymentRequestButton.mount("#payment-request-button");document.querySelector("#errors").innerHTML=JSON.parse((t=document.querySelector("meta[name=no-available-methods]"))==null?void 0:t.content),document.querySelector("#errors").hidden=!1}),this.handlePaymentRequestEvents(this.stripe,this.clientSecret)}}function a(){new m().handle()}o()?a():i("#stripe-browserpay-payment").then(()=>a());

View File

@@ -256,7 +256,7 @@
"src": "resources/js/clients/payments/stripe-becs.js"
},
"resources/js/clients/payments/stripe-browserpay.js": {
"file": "assets/stripe-browserpay-c23582f0.js",
"file": "assets/stripe-browserpay-91e96d1c.js",
"imports": [
"_wait-8f4ae121.js"
],

View File

@@ -1,144 +0,0 @@
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
import { instant, wait } from '../wait';
/**
* @typedef {Object} ApplePayOptions
* @property {string} publishable_key
* @property {string|null} account_id
* @property {string} country
* @property {string} currency
* @property {string} total_label
* @property {string} total_amount
* @property {string} client_secret
*/
function boot() {
applePay({
publishable_key: document.querySelector(
'meta[name="stripe-publishable-key"]'
)?.content,
account_id:
document.querySelector('meta[name="stripe-account-id"]')?.content ??
null,
country: document.querySelector('meta[name="stripe-country"]')?.content,
currency: document.querySelector('meta[name="stripe-currency"]')
?.content,
total_label: document.querySelector('meta[name="stripe-total-label"]')
?.content,
total_amount: document.querySelector('meta[name="stripe-total-amount"]')
?.content,
client_secret: document.querySelector(
'meta[name="stripe-client-secret"]'
)?.content,
});
}
instant() ? boot() : wait('#stripe-applepay-payment').then(() => boot());
/**
* @param {ApplePayOptions} options
*/
function applePay(options) {
let $options = {
apiVersion: '2024-06-20',
};
if (options.account_id) {
$options.stripeAccount = options.account_id;
}
const stripe = Stripe(options.publishable_key, $options);
const paymentRequest = stripe.paymentRequest({
country: options.country,
currency: options.currency,
total: {
label: options.total_label,
amount: options.total_amount,
},
requestPayerName: true,
requestPayerEmail: true,
});
const elements = stripe.elements();
const prButton = elements.create('paymentRequestButton', {
paymentRequest: paymentRequest,
});
// Check the availability of the Payment Request API first.
paymentRequest.canMakePayment().then(function (result) {
if (result) {
prButton.mount('#payment-request-button');
} else {
document.getElementById('payment-request-button').style.display =
'none';
}
});
paymentRequest.on('paymentmethod', function (ev) {
// Confirm the PaymentIntent without handling potential next actions (yet).
stripe
.confirmCardPayment(
options.client_secret,
{ payment_method: ev.paymentMethod.id },
{ handleActions: false }
)
.then(function (confirmResult) {
if (confirmResult.error) {
// Report to the browser that the payment failed, prompting it to
// re-show the payment interface, or show an error message and close
// the payment interface.
ev.complete('fail');
} else {
// Report to the browser that the confirmation was successful, prompting
// it to close the browser payment method collection interface.
ev.complete('success');
// Check if the PaymentIntent requires any actions and if so let Stripe.js
// handle the flow. If using an API version older than "2019-02-11"
// instead check for: `paymentIntent.status === "requires_source_action"`.
if (
confirmResult.paymentIntent.status === 'requires_action'
) {
// Let Stripe.js handle the rest of the payment flow.
stripe
.confirmCardPayment(clientSecret)
.then(function (result) {
if (result.error) {
// The payment failed -- ask your customer for a new payment method.
handleFailure(result.error);
} else {
// The payment has succeeded.
handleSuccess(result);
}
});
} else {
// The payment has succeeded.
}
}
});
});
function handleSuccess(result) {
document.querySelector('input[name="gateway_response"]').value =
JSON.stringify(result.paymentIntent);
document.getElementById('server-response').submit();
}
function handleFailure(message) {
let errors = document.getElementById('errors');
errors.textContent = '';
errors.textContent = message;
errors.hidden = false;
}
}

View File

@@ -40,11 +40,18 @@ class StripeBrowserPay {
}
createPaymentRequest() {
this.paymentRequest = this.stripe.paymentRequest(
JSON.parse(
document.querySelector('meta[name=payment-request-data').content
)
);
try {
this.paymentRequest = this.stripe.paymentRequest(
JSON.parse(
document.querySelector('meta[name=payment-request-data').content
)
);
} catch (e) {
document.querySelector('#errors').innerText = e.message;
document.querySelector('#errors').hidden = false;
throw e;
}
return this;
}
@@ -123,7 +130,11 @@ class StripeBrowserPay {
}
handle() {
this.init().createPaymentRequest().createPaymentRequestButton();
try {
this.init().createPaymentRequest().createPaymentRequestButton();
} catch (e) {
return;
}
this.paymentRequest.canMakePayment().then((result) => {
if (result) {

View File

@@ -36,10 +36,6 @@
new DocuNinjaSign({ document: doc, invitation, sig, endpoint: '{{ config('ninja.docuninja_api_url') }}', company }).mount(mount);
console.log('DocuNinjaSign mounted');
console.log('Document:', doc);
console.log('{{ config('ninja.docuninja_api_url') }}');
window.addEventListener('builder:sign.submit.success', function () {
Livewire.dispatch('docuninja-signature-captured');
});

View File

@@ -1,122 +0,0 @@
@extends('portal.ninja2020.layout.payments', ['gateway_title' => 'Apple Pay', 'card_title' => 'Apple Pay'])
@section('gateway_head')
<meta name="instant-payment" content="yes" />
@endsection
@section('gateway_content')
<form action="{{ route('client.payments.response') }}" method="post" id="server-response">
@csrf
<input type="hidden" name="gateway_response">
<input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="company_gateway_id" value="{{ $gateway->getCompanyGatewayId() }}">
<input type="hidden" name="payment_method_id" value="{{ $payment_method_id }}">
</form>
<div class="alert alert-failure mb-4" hidden id="errors"></div>
@include('portal.ninja2020.gateways.includes.payment_details')
<div id="payment-request-button">
<!-- A Stripe Element will be inserted here. -->
</div>
@endsection
@push('footer')
<script src="https://js.stripe.com/v3/"></script>
<script type="text/javascript">
@if($gateway->company_gateway->getConfigField('account_id'))
var stripe = Stripe('{{ config('ninja.ninja_stripe_publishable_key') }}', {
apiVersion: "2024-06-20",
stripeAccount: '{{ $gateway->company_gateway->getConfigField('account_id') }}',
});
@else
var stripe = Stripe('{{ $gateway->getPublishableKey() }}', {
apiVersion: "2024-06-20",
});
@endif
var paymentRequest = stripe.paymentRequest({
country: '{{ $country->iso_3166_2 }}',
currency: '{{ $currency }}',
total: {
label: '{{ ctrans('texts.payment_amount') }}',
amount: {{ $stripe_amount }},
},
requestPayerName: true,
requestPayerEmail: true,
});
var elements = stripe.elements();
var prButton = elements.create('paymentRequestButton', {
paymentRequest: paymentRequest,
});
// Check the availability of the Payment Request API first.
paymentRequest.canMakePayment().then(function(result) {
if (result) {
prButton.mount('#payment-request-button');
} else {
document.getElementById('payment-request-button').style.display = 'none';
}
});
paymentRequest.on('paymentmethod', function(ev) {
// Confirm the PaymentIntent without handling potential next actions (yet).
stripe.confirmCardPayment(
'{{ $intent->client_secret }}',
{payment_method: ev.paymentMethod.id},
{handleActions: false}
).then(function(confirmResult) {
if (confirmResult.error) {
// Report to the browser that the payment failed, prompting it to
// re-show the payment interface, or show an error message and close
// the payment interface.
ev.complete('fail');
} else {
// Report to the browser that the confirmation was successful, prompting
// it to close the browser payment method collection interface.
ev.complete('success');
// Check if the PaymentIntent requires any actions and if so let Stripe.js
// handle the flow. If using an API version older than "2019-02-11"
// instead check for: `paymentIntent.status === "requires_source_action"`.
if (confirmResult.paymentIntent.status === "requires_action") {
// Let Stripe.js handle the rest of the payment flow.
stripe.confirmCardPayment(clientSecret).then(function(result) {
if (result.error) {
// The payment failed -- ask your customer for a new payment method.
handleFailure(result.error)
} else {
// The payment has succeeded.
handleSuccess(result);
}
});
} else {
// The payment has succeeded.
}
}
});
});
handleSuccess(result) {
document.querySelector(
'input[name="gateway_response"]'
).value = JSON.stringify(result.paymentIntent);
document.getElementById('server-response').submit();
}
handleFailure(message) {
let errors = document.getElementById('errors');
errors.textContent = '';
errors.textContent = message;
errors.hidden = false;
}
</script>
@endpush

View File

@@ -1,31 +0,0 @@
<div class="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden py-5 bg-white sm:gap-4"
id="stripe-applepay-payment">
<meta name="stripe-publishable-key" content="{{ config('ninja.ninja_stripe_publishable_key') }}" />
<meta name="stripe-account-id" content="{{ $gateway->company_gateway->getConfigField('account_id') }}" />
<meta name="stripe-country" content="{{ $country->iso_3166_2 }}" />
<meta name="stripe-currency" content="{{ $currency }}" />
<meta name="stripe-total-label" content="{{ ctrans('texts.payment_amount') }}" />
<meta name="stripe-total-amount" content="{{ $stripe_amount }}" />
<meta name="stripe-client-secret" content="{{ $intent->client_secret }}" />
<form action="{{ route('client.payments.response') }}" method="post" id="server-response">
@csrf
<input type="hidden" name="gateway_response">
<input type="hidden" name="payment_hash" value="{{ $payment_hash }}">
<input type="hidden" name="company_gateway_id" value="{{ $gateway->getCompanyGatewayId() }}">
<input type="hidden" name="payment_method_id" value="{{ $payment_method_id }}">
</form>
<div class="alert alert-failure mb-4" hidden id="errors"></div>
@include('portal.ninja2020.gateways.includes.payment_details')
<div id="payment-request-button">
<!-- A Stripe Element will be inserted here. -->
</div>
</div>
@assets
<script src="https://js.stripe.com/v3/"></script>
@vite('resources/js/clients/payments/stripe-applepay.js')
@endassets

View File

@@ -0,0 +1,297 @@
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Client;
use App\Models\Invoice;
use Tests\MockAccountData;
use App\Utils\Traits\MakesHash;
use App\Factory\InvoiceItemFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Session;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class InvoiceBalanceTest extends TestCase
{
use MakesHash;
use DatabaseTransactions;
use MockAccountData;
protected function setUp(): void
{
parent::setUp();
Session::start();
Model::reguard();
$this->makeTestData();
}
private function makeLineItems(float $cost, int $quantity = 1): array
{
$item = InvoiceItemFactory::create();
$item->quantity = $quantity;
$item->cost = $cost;
return [(array) $item];
}
private function createInvoiceViaApi(string $clientHashId, array $lineItems): array
{
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/invoices', [
'status_id' => 1,
'number' => '',
'discount' => 0,
'is_amount_discount' => 1,
'client_id' => $clientHashId,
'line_items' => $lineItems,
])->assertStatus(200);
return $response->json()['data'];
}
/**
* Test that editing a SENT invoice's amount correctly updates the client balance.
* This is the scenario: invoice at $756, updated to $805.
*/
public function test_client_balance_updates_when_sent_invoice_amount_changes()
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
]);
$client->balance = 0;
$client->saveQuietly();
// Create invoice at $756 and mark sent
$line_items = $this->makeLineItems(756);
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
$invoice = $invoice->service()->markSent()->save();
$client->refresh();
$this->assertEquals(756, $client->balance, 'Client balance should be 756 after marking sent');
// Now update the invoice amount to $805 via PUT (the alternativeSave path)
$new_line_items = $this->makeLineItems(805);
$this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/invoices/' . $data['id'], [
'client_id' => $client->hashed_id,
'line_items' => $new_line_items,
])->assertStatus(200);
$client->refresh();
$invoice->refresh();
$this->assertEquals(805, $invoice->amount, 'Invoice amount should be 805');
$this->assertEquals(805, $invoice->balance, 'Invoice balance should be 805');
$this->assertEquals(805, $client->balance, 'Client balance should be 805 after editing sent invoice');
}
/**
* Test that editing a SENT invoice then paying in full results in zero balance.
* Reproduces the exact $756 -> $805 -> pay $805 -> balance should be $0 scenario.
*/
public function test_edit_sent_invoice_then_full_payment_results_in_zero_balance()
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
]);
$client->balance = 0;
$client->saveQuietly();
// Create invoice at $756 and mark sent
$line_items = $this->makeLineItems(756);
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
$invoice = $invoice->service()->markSent()->save();
$client->refresh();
$this->assertEquals(756, $client->balance);
// Update to $805
$new_line_items = $this->makeLineItems(805);
$this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/invoices/' . $data['id'], [
'client_id' => $client->hashed_id,
'line_items' => $new_line_items,
])->assertStatus(200);
$client->refresh();
$this->assertEquals(805, $client->balance, 'Client balance should be 805 after edit');
// Pay in full - $805
$invoice->refresh();
$this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->postJson('/api/v1/payments', [
'amount' => 805,
'client_id' => $client->hashed_id,
'invoices' => [
[
'invoice_id' => $invoice->hashed_id,
'amount' => 805,
],
],
'date' => '2024/01/01',
])->assertStatus(200);
$client->refresh();
$invoice->refresh();
$this->assertEquals(0, $invoice->balance, 'Invoice balance should be 0 after full payment');
$this->assertEquals(0, $client->balance, 'Client balance should be 0 after full payment');
}
/**
* Test that marking a draft invoice as sent only increments client balance once.
*/
public function test_mark_sent_increments_client_balance_once()
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
]);
$client->balance = 0;
$client->saveQuietly();
$line_items = $this->makeLineItems(500);
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
$client->refresh();
$this->assertEquals(0, $client->balance, 'Client balance should be 0 for draft invoice');
// Mark sent via triggered actions (PUT with mark_sent=true)
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
$this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/invoices/' . $data['id'] . '?mark_sent=true', [
'client_id' => $client->hashed_id,
'line_items' => $this->makeLineItems(500),
])->assertStatus(200);
$client->refresh();
$this->assertEquals(500, $client->balance, 'Client balance should be 500 after mark_sent (not double)');
}
/**
* Test that decreasing a sent invoice amount correctly reduces client balance.
*/
public function test_decreasing_sent_invoice_amount_reduces_client_balance()
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
]);
$client->balance = 0;
$client->saveQuietly();
// Create and mark sent at $1000
$line_items = $this->makeLineItems(1000);
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
$invoice = $invoice->service()->markSent()->save();
$client->refresh();
$this->assertEquals(1000, $client->balance);
// Reduce to $750
$this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/invoices/' . $data['id'], [
'client_id' => $client->hashed_id,
'line_items' => $this->makeLineItems(750),
])->assertStatus(200);
$client->refresh();
$this->assertEquals(750, $client->balance, 'Client balance should decrease to 750');
}
/**
* Test that editing a sent invoice with no amount change does not affect client balance.
*/
public function test_editing_sent_invoice_notes_does_not_change_balance()
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
]);
$client->balance = 0;
$client->saveQuietly();
$line_items = $this->makeLineItems(300);
$data = $this->createInvoiceViaApi($client->hashed_id, $line_items);
$invoice = Invoice::find($this->decodePrimaryKey($data['id']));
$invoice = $invoice->service()->markSent()->save();
$client->refresh();
$this->assertEquals(300, $client->balance);
// Update only notes, not line items
$this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/invoices/' . $data['id'], [
'client_id' => $client->hashed_id,
'public_notes' => 'Updated notes only',
'line_items' => $this->makeLineItems(300),
])->assertStatus(200);
$client->refresh();
$this->assertEquals(300, $client->balance, 'Client balance should remain 300 when amount unchanged');
}
/**
* Test that multiple sent invoices for same client have correct cumulative balance.
*/
public function test_multiple_sent_invoices_correct_cumulative_balance()
{
$client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
]);
$client->balance = 0;
$client->saveQuietly();
// Invoice 1: $200
$data1 = $this->createInvoiceViaApi($client->hashed_id, $this->makeLineItems(200));
$inv1 = Invoice::find($this->decodePrimaryKey($data1['id']));
$inv1->service()->markSent()->save();
$client->refresh();
$this->assertEquals(200, $client->balance);
// Invoice 2: $300
$data2 = $this->createInvoiceViaApi($client->hashed_id, $this->makeLineItems(300));
$inv2 = Invoice::find($this->decodePrimaryKey($data2['id']));
$inv2->service()->markSent()->save();
$client->refresh();
$this->assertEquals(500, $client->balance);
// Edit invoice 1 from $200 to $350
$this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->token,
])->putJson('/api/v1/invoices/' . $data1['id'], [
'client_id' => $client->hashed_id,
'line_items' => $this->makeLineItems(350),
])->assertStatus(200);
$client->refresh();
$this->assertEquals(650, $client->balance, 'Client balance should be 350 + 300 = 650');
}
}

View File

@@ -210,7 +210,7 @@ class ReminderTest extends TestCase
} while ($x === false);
$this->assertNotNull($invoice->reminder_last_sent);
$this->assertEquals(now()->addDays(7), $invoice->next_send_date);
$this->assertEquals(now()->addDays(7)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
$this->assertNotNull($invoice->reminder1_sent);
$this->assertEquals('2024-09-01', \Carbon\Carbon::parse($invoice->reminder_last_sent)->format('Y-m-d'));
@@ -409,7 +409,7 @@ class ReminderTest extends TestCase
} while ($x === false);
$this->assertNotNull($invoice->reminder_last_sent);
$this->assertEquals(now()->addDays(1), $invoice->next_send_date);
$this->assertEquals(now()->addDays(1)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
$x = 0;
do {
@@ -421,7 +421,7 @@ class ReminderTest extends TestCase
$x++;
} while ($x < 24);
$this->assertEquals(now()->addDays(1), $invoice->next_send_date);
$this->assertEquals(now()->addDays(1)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
@@ -501,18 +501,11 @@ class ReminderTest extends TestCase
$this->assertNotNull($invoice->reminder_last_sent);
//check next send date is on day "10"
$this->assertEquals(now()->addDays(5), $invoice->next_send_date);
$this->assertEquals(now()->addDays(5)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
$this->travelTo(now()->copy()->addDays(5)->startOfDay()->addHours(5));
$this->travelTo(now()->copy()->addDays(5)->startOfDay());
$this->assertEquals('2024-03-11', now()->format('Y-m-d'));
$this->travelTo(now()->copy()->addHour());
(new ReminderJob())->handle();
$invoice = $invoice->fresh();
$this->assertGreaterThan(0, $invoice->balance);
$this->assertNull($invoice->reminder2_sent);
$x = false;
do {
@@ -523,24 +516,19 @@ class ReminderTest extends TestCase
$x = (bool)$invoice->reminder2_sent;
} while ($x === false);
$this->assertGreaterThan(0, $invoice->balance);
$this->assertNotNull($invoice->reminder2_sent);
$this->assertEquals($invoice->reminder2_sent, $invoice->reminder_last_sent);
$this->assertEquals(now()->addDays(5), $invoice->next_send_date);
$this->assertEquals(now()->addDays(5)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
//check next send date is on day "15"
$this->assertEquals(now()->addDays(5), $invoice->next_send_date);
$this->assertEquals(now()->addDays(5)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
$this->travelTo(now()->copy()->addDays(5)->startOfDay()->addHours(5));
$this->travelTo(now()->copy()->addDays(5)->startOfDay());
$this->assertEquals('2024-03-16', now()->format('Y-m-d'));
$this->travelTo(now()->copy()->addHour());
(new ReminderJob())->handle();
$invoice = $invoice->fresh();
$this->assertGreaterThan(0, $invoice->balance);
$this->assertNull($invoice->reminder3_sent);
$x = false;
do {
@@ -551,11 +539,13 @@ class ReminderTest extends TestCase
$x = (bool)$invoice->reminder3_sent;
} while ($x === false);
$this->assertGreaterThan(0, $invoice->balance);
$this->assertNotNull($invoice->reminder3_sent);
$this->assertEquals($invoice->reminder3_sent, $invoice->reminder_last_sent);
//endless reminders
$this->assertEquals(now()->addDays(14), $invoice->next_send_date);
$this->assertEquals(now()->addDays(14)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
$this->travelTo(now()->addDays(14)->startOfDay());
@@ -573,7 +563,7 @@ class ReminderTest extends TestCase
} while ($x === false);
$this->assertEquals(now()->addDays(14), $invoice->next_send_date);
$this->assertEquals(now()->addDays(14)->format('Y-m-d'), \Carbon\Carbon::parse($invoice->next_send_date)->format('Y-m-d'));
}

View File

@@ -929,6 +929,7 @@ class UserTest extends TestCase
// Perform the purge
$user_repo = new UserRepository();
$owner_user->setCompany($company);
$user_repo->purge($secondary_user, $owner_user);
// Assert secondary user is deleted

View File

@@ -492,6 +492,7 @@ class LateFeeTest extends TestCase
$settings->num_days_reminder1 = 10;
$settings->schedule_reminder1 = 'after_due_date';
$settings->entity_send_time = 6;
$settings->timezone_id = '33';
$client = $this->buildData($settings);