mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2026-03-03 02:57:01 +00:00
@@ -1 +1 @@
|
||||
5.12.65
|
||||
5.12.66
|
||||
@@ -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,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
50
app/Casts/PurchaseOrderSyncCast.php
Normal file
50
app/Casts/PurchaseOrderSyncCast.php
Normal 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,
|
||||
])
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
100
app/DataMapper/PurchaseOrderSync.php
Normal file
100
app/DataMapper/PurchaseOrderSync.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
52
app/Events/User/UserWasPurged.php
Normal file
52
app/Events/User/UserWasPurged.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,7 @@ class BlackListRule implements ValidationRule
|
||||
{
|
||||
/** Bad domains +/- disposable email domains */
|
||||
private array $blacklist = [
|
||||
"fxzig.com",
|
||||
"dollicons.com",
|
||||
"mypost.lol",
|
||||
"mozmail.com",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
55
app/Listeners/User/PurgedUserActivity.php
Normal file
55
app/Listeners/User/PurgedUserActivity.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -223,10 +223,6 @@
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/turbo124/snappdf"
|
||||
},
|
||||
{
|
||||
"type": "path",
|
||||
"url": "../admin-api"
|
||||
}
|
||||
],
|
||||
"minimum-stability": "dev",
|
||||
|
||||
60
composer.lock
generated
60
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
42
package-lock.json
generated
@@ -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
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
183
public/build/assets/builder.iife.js
vendored
183
public/build/assets/builder.iife.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
public/build/assets/stripe-browserpay-91e96d1c.js
vendored
Normal file
9
public/build/assets/stripe-browserpay-91e96d1c.js
vendored
Normal 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());
|
||||
@@ -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());
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
144
resources/js/clients/payments/stripe-applepay.js
vendored
144
resources/js/clients/payments/stripe-applepay.js
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
297
tests/Feature/InvoiceBalanceTest.php
Normal file
297
tests/Feature/InvoiceBalanceTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user