commit
9e17c85f1b
|
|
@ -1 +1 @@
|
|||
5.12.35
|
||||
5.12.36
|
||||
|
|
@ -335,12 +335,11 @@ class RebuildElasticIndexes extends Command
|
|||
$this->line(" Waiting for our {$expectedJobCount} jobs to complete...");
|
||||
$this->line(" (Tracking: pending + processing jobs)", 'comment');
|
||||
|
||||
$maxWaitSeconds = 600;
|
||||
$startTime = time();
|
||||
$lastReportedDelta = -1;
|
||||
$stableCount = 0;
|
||||
|
||||
while ((time() - $startTime) < $maxWaitSeconds) {
|
||||
while (true) {
|
||||
try {
|
||||
$currentJobCount = $this->getTotalActiveJobCount($connection, $queueName);
|
||||
$delta = $currentJobCount - $baselineJobCount;
|
||||
|
|
@ -371,13 +370,6 @@ class RebuildElasticIndexes extends Command
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$finalCount = $this->getTotalActiveJobCount($connection, $queueName);
|
||||
$this->warn(" ⚠ Timeout after {$maxWaitSeconds}s (active: {$finalCount}, baseline: {$baselineJobCount})");
|
||||
} catch (\Exception $e) {
|
||||
$this->warn(" ⚠ Timeout after {$maxWaitSeconds}s - continuing");
|
||||
}
|
||||
}
|
||||
|
||||
protected function getTotalActiveJobCount(string $connection, string $queueName): int
|
||||
|
|
|
|||
|
|
@ -22,8 +22,12 @@ class Cancellation
|
|||
public int $status_id = 0 //The status id of the invoice when it was cancelled
|
||||
) {}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
public static function fromArray(array|object $data): self
|
||||
{
|
||||
if (is_object($data)) {
|
||||
$data = (array) $data;
|
||||
}
|
||||
|
||||
return new self(
|
||||
adjustment: $data['adjustment'] ?? 0,
|
||||
status_id: $data['status_id'] ?? 0
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class CreditController extends Controller
|
|||
|
||||
$data = [
|
||||
'credit' => $credit,
|
||||
'key' => $invitation ? $invitation->key : false,
|
||||
'_key' => $invitation ? $invitation->key : false,
|
||||
'invitation' => $invitation
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class InvoiceController extends Controller
|
|||
$data = [
|
||||
'invoice' => $invoice->service()->removeUnpaidGatewayFees()->save(),
|
||||
'invitation' => $invitation ?: $invoice->invitations->first(),
|
||||
'key' => $invitation ? $invitation->key : false,
|
||||
'_key' => $invitation ? $invitation->key : false,
|
||||
'hash' => $hash,
|
||||
'variables' => $variables,
|
||||
'invoices' => [$invoice->hashed_id],
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class QuoteController extends Controller
|
|||
|
||||
$data = [
|
||||
'quote' => $quote,
|
||||
'key' => $invitation ? $invitation->key : false,
|
||||
'_key' => $invitation ? $invitation->key : false,
|
||||
'invitation' => $invitation,
|
||||
'variables' => $variables,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\Gateways;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request; // Import the Request class
|
||||
use Illuminate\Support\Facades\Http; // Import the Http facade
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
|
||||
class BlockonomicsController extends Controller
|
||||
{
|
||||
public function getBTCPrice(Request $request)
|
||||
{
|
||||
$currency = $request->query('currency');
|
||||
$response = Http::get("https://www.blockonomics.co/api/price?currency={$currency}");
|
||||
|
||||
if ($response->successful()) {
|
||||
return response()->json(['price' => $response->json('price')]);
|
||||
}
|
||||
|
||||
return response()->json(['error' => 'Unable to fetch BTC price'], 500);
|
||||
}
|
||||
|
||||
public function getQRCode(Request $request)
|
||||
{
|
||||
$qr_string = $request->query('qr_string');
|
||||
$svg = $this->getPaymentQrCodeRaw($qr_string);
|
||||
return response($svg)->header('Content-Type', 'image/svg+xml');
|
||||
}
|
||||
|
||||
private function getPaymentQrCodeRaw($qr_string)
|
||||
{
|
||||
|
||||
$renderer = new ImageRenderer(
|
||||
new RendererStyle(150, margin: 0),
|
||||
new SvgImageBackEnd()
|
||||
);
|
||||
$writer = new Writer($renderer);
|
||||
|
||||
$qr = $writer->writeString($qr_string, 'utf-8');
|
||||
|
||||
return $qr;
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +100,7 @@ class PurchaseOrderController extends Controller
|
|||
|
||||
$data = [
|
||||
'purchase_order' => $purchase_order,
|
||||
'key' => $invitation ? $invitation->key : false,
|
||||
'_key' => $invitation ? $invitation->key : false,
|
||||
'settings' => $purchase_order->company->settings,
|
||||
'sidebar' => $this->sidebarMenu(),
|
||||
'company' => $purchase_order->company,
|
||||
|
|
|
|||
|
|
@ -84,7 +84,8 @@ class StorePurchaseOrderRequest extends Request
|
|||
|
||||
$input['amount'] = 0;
|
||||
$input['balance'] = 0;
|
||||
|
||||
$input['total_taxes'] = 0;
|
||||
|
||||
if ($this->file('documents') instanceof \Illuminate\Http\UploadedFile) {
|
||||
$this->files->set('documents', [$this->file('documents')]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ class StoreTaskRequest extends Request
|
|||
|
||||
$rules['hash'] = 'bail|sometimes|string|nullable';
|
||||
|
||||
$rules['time_log'] = ['bail',function ($attribute, $values, $fail) {
|
||||
$rules['time_log'] = ['bail', function ($attribute, $values, $fail) {
|
||||
|
||||
if (is_string($values)) {
|
||||
$values = json_decode($values, true);
|
||||
|
|
@ -68,20 +68,42 @@ class StoreTaskRequest extends Request
|
|||
return;
|
||||
}
|
||||
|
||||
foreach ($values as $k) {
|
||||
foreach ($values as $key => $k) {
|
||||
|
||||
// Check if this is an array
|
||||
if (!is_array($k)) {
|
||||
return $fail('Time log entry at position '.$key.' must be an array.');
|
||||
}
|
||||
|
||||
// Check for associative array (has string keys)
|
||||
if (array_keys($k) !== range(0, count($k) - 1)) {
|
||||
return $fail('Time log entry at position '.$key.' uses invalid format. Expected: [unix_start, unix_end, description, billable]. Received associative array with keys: '.implode(', ', array_keys($k)));
|
||||
}
|
||||
|
||||
// Ensure minimum required elements exist
|
||||
if (!isset($k[0]) || !isset($k[1])) {
|
||||
return $fail('Time log entry at position '.$key.' must have at least 2 elements: [start_timestamp, end_timestamp].');
|
||||
}
|
||||
|
||||
// Validate types for required elements
|
||||
if (!is_int($k[0]) || !is_int($k[1])) {
|
||||
return $fail('The '.$attribute.' - '.print_r($k, true).' is invalid. Unix timestamps only.');
|
||||
return $fail('Time log entry at position '.$key.' is invalid. Elements [0] and [1] must be Unix timestamps (integers). Received: '.print_r($k, true));
|
||||
}
|
||||
|
||||
// Validate max elements
|
||||
if(count($k) > 4) {
|
||||
return $fail('The timelog can only have up to 4 elements.');
|
||||
}
|
||||
|
||||
if (isset($k[3]) && !is_bool($k[3])) {
|
||||
return $fail('The '.$attribute.' - '.print_r($k, true).' is invalid. The 4th element must be a boolean.');
|
||||
return $fail('Time log entry at position '.$key.' can only have up to 4 elements. Received '.count($k).' elements.');
|
||||
}
|
||||
|
||||
// Validate optional element [2] (description)
|
||||
if (isset($k[2]) && !is_string($k[2])) {
|
||||
return $fail('Time log entry at position '.$key.': element [2] (description) must be a string. Received: '.gettype($k[2]));
|
||||
}
|
||||
|
||||
// Validate optional element [3] (billable)
|
||||
if(isset($k[3]) && !is_bool($k[3])) {
|
||||
return $fail('Time log entry at position '.$key.': element [3] (billable) must be a boolean. Received: '.gettype($k[3]));
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->checkTimeLog($values)) {
|
||||
|
|
@ -128,8 +150,8 @@ class StoreTaskRequest extends Request
|
|||
continue; //catch if it isn't even a proper time log
|
||||
}
|
||||
|
||||
$time_log[0] = intval($time_log[0]);
|
||||
$time_log[1] = intval($time_log[1]);
|
||||
$time_log[0] = intval($time_log[0] ?? 0);
|
||||
$time_log[1] = intval($time_log[1] ?? 0);
|
||||
$time_log[2] = strval($time_log[2] ?? '');
|
||||
$time_log[3] = boolval($time_log[3] ?? true);
|
||||
|
||||
|
|
@ -142,7 +164,7 @@ class StoreTaskRequest extends Request
|
|||
/* Ensure the project is related */
|
||||
if (array_key_exists('project_id', $input) && isset($input['project_id'])) {
|
||||
$project = Project::withTrashed()->where('id', $input['project_id'])->company()->first();
|
||||
;
|
||||
|
||||
if ($project) {
|
||||
$input['client_id'] = $project->client_id;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -74,17 +74,41 @@ class UpdateTaskRequest extends Request
|
|||
return;
|
||||
}
|
||||
|
||||
foreach ($values as $k) {
|
||||
foreach ($values as $key => $k) {
|
||||
|
||||
// Check if this is an array
|
||||
if (!is_array($k)) {
|
||||
return $fail('Time log entry at position '.$key.' must be an array.');
|
||||
}
|
||||
|
||||
// Check for associative array (has string keys)
|
||||
if (array_keys($k) !== range(0, count($k) - 1)) {
|
||||
return $fail('Time log entry at position '.$key.' uses invalid format. Expected: [unix_start, unix_end, description, billable]. Received associative array with keys: '.implode(', ', array_keys($k)));
|
||||
}
|
||||
|
||||
// Ensure minimum required elements exist
|
||||
if (!isset($k[0]) || !isset($k[1])) {
|
||||
return $fail('Time log entry at position '.$key.' must have at least 2 elements: [start_timestamp, end_timestamp].');
|
||||
}
|
||||
|
||||
// Validate types for required elements
|
||||
if (!is_int($k[0]) || !is_int($k[1])) {
|
||||
return $fail('The '.$attribute.' - '.print_r($k, true).' is invalid. Unix timestamps only.');
|
||||
return $fail('Time log entry at position '.$key.' is invalid. Elements [0] and [1] must be Unix timestamps (integers). Received: '.print_r($k, true));
|
||||
}
|
||||
|
||||
// Validate max elements
|
||||
if(count($k) > 4) {
|
||||
return $fail('The timelog can only have up to 4 elements.');
|
||||
return $fail('Time log entry at position '.$key.' can only have up to 4 elements. Received '.count($k).' elements.');
|
||||
}
|
||||
|
||||
// Validate optional element [2] (description)
|
||||
if (isset($k[2]) && !is_string($k[2])) {
|
||||
return $fail('Time log entry at position '.$key.': element [2] (description) must be a string. Received: '.gettype($k[2]));
|
||||
}
|
||||
|
||||
// Validate optional element [3] (billable)
|
||||
if(isset($k[3]) && !is_bool($k[3])) {
|
||||
return $fail('The '.$attribute.' - '.print_r($k, true).' is invalid. The 4th element must be a boolean.');
|
||||
return $fail('Time log entry at position '.$key.': element [3] (billable) must be a boolean. Received: '.gettype($k[3]));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class BlackListRule implements ValidationRule
|
|||
{
|
||||
/** Bad domains +/- disposable email domains */
|
||||
private array $blacklist = [
|
||||
"bablace.com",
|
||||
"moonfee.com",
|
||||
"edus2.us",
|
||||
"educj.org",
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ class CreatedCreditActivity implements ShouldQueue
|
|||
|
||||
$user_id = isset($event->event_vars['user_id']) ? $event->event_vars['user_id'] : $event->credit->user_id;
|
||||
|
||||
if($event->credit->invoice_id) {
|
||||
$fields->invoice_id = $event->credit->invoice_id;
|
||||
}
|
||||
|
||||
$fields->credit_id = $event->credit->id;
|
||||
$fields->user_id = $user_id;
|
||||
$fields->client_id = $event->credit->client_id;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ class DeleteCreditActivity implements ShouldQueue
|
|||
|
||||
$user_id = isset($event->event_vars['user_id']) ? $event->event_vars['user_id'] : $event->credit->user_id;
|
||||
|
||||
if($event->credit->invoice_id) {
|
||||
$fields->invoice_id = $event->credit->invoice_id;
|
||||
}
|
||||
|
||||
$fields->client_id = $event->credit->client_id;
|
||||
$fields->credit_id = $event->credit->id;
|
||||
$fields->user_id = $user_id;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ class UpdatedCreditActivity implements ShouldQueue
|
|||
|
||||
$user_id = isset($event->event_vars['user_id']) ? $event->event_vars['user_id'] : $event->credit->user_id;
|
||||
|
||||
|
||||
if($event->credit->invoice_id) {
|
||||
$fields->invoice_id = $event->credit->invoice_id;
|
||||
}
|
||||
|
||||
$fields->credit_id = $event->credit->id;
|
||||
$fields->client_id = $event->credit->client_id;
|
||||
$fields->user_id = $user_id;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Livewire\BillingPortal\Payments;
|
||||
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class BlockonomicsPriceDisplay extends Component
|
||||
{
|
||||
public $currency;
|
||||
|
||||
public $btc_price;
|
||||
|
||||
public $btc_amount;
|
||||
|
||||
public $countdown = '10:00';
|
||||
|
||||
public $is_refreshing = false;
|
||||
|
||||
protected $listeners = ['refresh-btc-price' => 'refreshBTCPrice'];
|
||||
|
||||
public function mount($currency, $btc_price, $btc_amount)
|
||||
{
|
||||
$this->currency = $currency;
|
||||
$this->btc_price = $btc_price;
|
||||
$this->btc_amount = $btc_amount;
|
||||
// Countdown will be initialized in the JavaScript @script section
|
||||
}
|
||||
|
||||
public function refreshBTCPrice()
|
||||
{
|
||||
$this->is_refreshing = true;
|
||||
|
||||
nlog('Refreshing BTC price');
|
||||
try {
|
||||
$response = Http::get('https://www.blockonomics.co/api/price', [
|
||||
'currency' => $this->currency,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$price = $response->object()->price ?? null;
|
||||
|
||||
if ($price) {
|
||||
$this->btc_price = $price;
|
||||
$this->btc_amount = number_format($this->btc_amount / $price, 10);
|
||||
|
||||
// Reset the countdown
|
||||
$this->startCountdown();
|
||||
$this->dispatch('btc-price-updated', [
|
||||
'price' => $price,
|
||||
'amount' => $this->btc_amount,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
} finally {
|
||||
$this->is_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function startCountdown()
|
||||
{
|
||||
$this->countdown = '10:00';
|
||||
$this->dispatch('start-countdown', ['duration' => 600]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return render('components.livewire.blockonomics-price-display');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://www.elastic.co/licensing/elastic-license
|
||||
*/
|
||||
|
||||
namespace App\Livewire\BillingPortal\Payments;
|
||||
|
||||
use BaconQrCode\Writer;
|
||||
use Livewire\Component;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
|
||||
class BlockonomicsQrCode extends Component
|
||||
{
|
||||
public $btc_address;
|
||||
|
||||
public $btc_amount;
|
||||
|
||||
public $qr_code_svg = '';
|
||||
|
||||
public $is_loading = false;
|
||||
|
||||
public $error_message = '';
|
||||
|
||||
public function mount($btc_address, $btc_amount)
|
||||
{
|
||||
$this->btc_address = $btc_address;
|
||||
$this->btc_amount = $btc_amount;
|
||||
$this->fetchQRCode();
|
||||
}
|
||||
|
||||
public function fetchQRCode($newBtcAmount = null)
|
||||
{
|
||||
$this->is_loading = true;
|
||||
$this->error_message = '';
|
||||
|
||||
try {
|
||||
$btcAmount = $newBtcAmount ?? $this->btc_amount;
|
||||
$qrString = "bitcoin:{$this->btc_address}?amount={$btcAmount}";
|
||||
|
||||
$this->qr_code_svg = $this->getPaymentQrCodeRaw($qrString);
|
||||
} catch (\Exception $e) {
|
||||
$this->error_message = 'Error generating QR code';
|
||||
} finally {
|
||||
$this->is_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private function getPaymentQrCodeRaw($qr_string)
|
||||
{
|
||||
|
||||
$renderer = new ImageRenderer(
|
||||
new RendererStyle(150, margin: 0),
|
||||
new SvgImageBackEnd()
|
||||
);
|
||||
$writer = new Writer($renderer);
|
||||
|
||||
$qr = $writer->writeString($qr_string, 'utf-8');
|
||||
|
||||
return $qr;
|
||||
|
||||
}
|
||||
|
||||
public function updateQRCode($btcAmount)
|
||||
{
|
||||
$this->btc_amount = $btcAmount;
|
||||
$this->fetchQRCode($btcAmount);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return render('components.livewire.blockonomics-qr-code');
|
||||
}
|
||||
}
|
||||
|
|
@ -142,7 +142,7 @@ class Blockonomics implements LivewireMethodInterface
|
|||
'btc_price' => ['required'],
|
||||
]);
|
||||
|
||||
$this->payment_hash = PaymentHash::where('hash', $request->payment_hash)->firstOrFail();
|
||||
$this->blockonomics->payment_hash = PaymentHash::where('hash', $request->payment_hash)->firstOrFail();
|
||||
|
||||
// Calculate fiat amount from Bitcoin
|
||||
$amount_received_satoshis = $request->btc_amount;
|
||||
|
|
@ -152,16 +152,12 @@ class Blockonomics implements LivewireMethodInterface
|
|||
$fiat_amount = round(($price_per_btc_in_fiat * $amount_received_btc), 2);
|
||||
|
||||
// Get the expected amount from payment hash
|
||||
$payment_hash_data = $this->payment_hash->data;
|
||||
$payment_hash_data = $this->blockonomics->payment_hash->data;
|
||||
$expected_amount = $payment_hash_data->amount_with_fee;
|
||||
|
||||
// Adjust invoice allocations to match actual received amount if the amounts don't match
|
||||
if (!BcMath::equal($fiat_amount, $expected_amount)) {
|
||||
$this->adjustInvoiceAllocations($fiat_amount);
|
||||
|
||||
// Update the blockonomics driver's payment_hash reference
|
||||
// This is the key - the driver needs the updated payment_hash
|
||||
$this->blockonomics->payment_hash = $this->payment_hash;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -216,7 +212,7 @@ class Blockonomics implements LivewireMethodInterface
|
|||
*/
|
||||
private function adjustInvoiceAllocations(float $amount_received): void
|
||||
{
|
||||
$payment_hash_data = $this->payment_hash->data;
|
||||
$payment_hash_data = $this->blockonomics->payment_hash->data;
|
||||
|
||||
// Get the invoices array from payment hash data
|
||||
$invoices = $payment_hash_data->invoices ?? [];
|
||||
|
|
@ -274,8 +270,8 @@ class Blockonomics implements LivewireMethodInterface
|
|||
$payment_hash_data->invoices = $adjusted_invoices;
|
||||
$payment_hash_data->amount_with_fee = $amount_received; // Critical: Update total amount
|
||||
|
||||
$this->payment_hash->data = $payment_hash_data;
|
||||
$this->payment_hash->save();
|
||||
$this->blockonomics->payment_hash->data = $payment_hash_data;
|
||||
$this->blockonomics->payment_hash->save();
|
||||
}
|
||||
// Not supported yet
|
||||
public function refund(Payment $payment, $amount)
|
||||
|
|
|
|||
|
|
@ -27,10 +27,6 @@ class ClientGatewayTokenRepository extends BaseRepository
|
|||
$client_gateway_token->company_gateway_id = $data['company_gateway_id'];
|
||||
}
|
||||
|
||||
if (isset($data['is_default']) && !boolval($data['is_default'])) {
|
||||
$client_gateway_token->is_default = false;
|
||||
}
|
||||
|
||||
$client_gateway_token->save();
|
||||
|
||||
if (isset($data['is_default']) && boolval($data['is_default'])) {
|
||||
|
|
@ -45,6 +41,7 @@ class ClientGatewayTokenRepository extends BaseRepository
|
|||
ClientGatewayToken::withTrashed()
|
||||
->where('company_id', $client_gateway_token->company_id)
|
||||
->where('client_id', $client_gateway_token->client_id)
|
||||
->where('id', '!=', $client_gateway_token->id)
|
||||
->update(['is_default' => false]);
|
||||
|
||||
$client_gateway_token->is_default = true;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ use App\Export\CSV\BaseExport;
|
|||
use App\Utils\Traits\MakesDates;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use App\Services\Template\TemplateService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ARSummaryReport extends BaseExport
|
||||
{
|
||||
|
|
@ -40,6 +42,19 @@ class ARSummaryReport extends BaseExport
|
|||
private array $clients = [];
|
||||
|
||||
private string $template = '/views/templates/reports/ar_summary_report.html';
|
||||
|
||||
/**
|
||||
* Flag to use optimized query (single query vs N+1).
|
||||
* Set to false to rollback to legacy implementation.
|
||||
*/
|
||||
private bool $useOptimizedQuery = true;
|
||||
|
||||
/**
|
||||
* Chunk size for whereIn queries to avoid SQL limits.
|
||||
* MySQL has max_allowed_packet and whereIn performance degrades with large arrays.
|
||||
* Set to 1000 to safely handle 100,000+ clients without hitting SQL limits.
|
||||
*/
|
||||
private int $chunkSize = 1000;
|
||||
|
||||
public array $report_keys = [
|
||||
'client_name',
|
||||
|
|
@ -92,6 +107,21 @@ class ARSummaryReport extends BaseExport
|
|||
|
||||
$this->csv->insertOne($this->buildHeader());
|
||||
|
||||
if ($this->useOptimizedQuery) {
|
||||
$this->runOptimized();
|
||||
} else {
|
||||
$this->runLegacy();
|
||||
}
|
||||
|
||||
return $this->csv->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy implementation: N+1 query approach (6 queries per client).
|
||||
* Preserved for easy rollback if needed.
|
||||
*/
|
||||
private function runLegacy(): void
|
||||
{
|
||||
$query = Client::query()
|
||||
->where('company_id', $this->company->id)
|
||||
->where('is_deleted', 0);
|
||||
|
|
@ -99,14 +129,47 @@ class ARSummaryReport extends BaseExport
|
|||
$query = $this->filterByUserPermissions($query);
|
||||
|
||||
$query->orderBy('balance', 'desc')
|
||||
->cursor()
|
||||
->each(function ($client) {
|
||||
/** @var \App\Models\Client $client */
|
||||
$this->csv->insertOne($this->buildRow($client));
|
||||
->cursor()
|
||||
->each(function ($client) {
|
||||
/** @var \App\Models\Client $client */
|
||||
$this->csv->insertOne($this->buildRow($client));
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
/**
|
||||
* Optimized implementation: Single query with CASE statements.
|
||||
* Reduces 6 queries per client to 1 query total.
|
||||
*/
|
||||
private function runOptimized(): void
|
||||
{
|
||||
// Get all client IDs with permission filtering
|
||||
$query = Client::query()
|
||||
->where('company_id', $this->company->id)
|
||||
->where('is_deleted', 0);
|
||||
|
||||
return $this->csv->toString();
|
||||
$query = $this->filterByUserPermissions($query);
|
||||
|
||||
// Process clients in chunks to avoid whereIn() SQL limits
|
||||
// For 100,000 clients, this creates 100 chunks with 1 query each
|
||||
$query->orderBy('balance', 'desc')
|
||||
->chunk($this->chunkSize, function ($clientChunk) {
|
||||
$clientIds = $clientChunk->pluck('id')->toArray();
|
||||
|
||||
if (empty($clientIds)) {
|
||||
return true; // Continue to next chunk
|
||||
}
|
||||
|
||||
// Fetch aging data for this chunk (1 query per chunk)
|
||||
$agingData = $this->getAgingDataOptimized($clientIds);
|
||||
|
||||
// Build rows from cached data
|
||||
foreach ($clientChunk as $client) {
|
||||
/** @var \App\Models\Client $client */
|
||||
$this->csv->insertOne($this->buildRowOptimized($client, $agingData));
|
||||
}
|
||||
|
||||
return true; // Continue to next chunk
|
||||
});
|
||||
}
|
||||
|
||||
public function getPdf()
|
||||
|
|
@ -134,6 +197,124 @@ class ARSummaryReport extends BaseExport
|
|||
return $ts_instance->getPdf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all aging data for multiple clients in a single query.
|
||||
* Uses CASE statements to calculate all aging buckets in one pass.
|
||||
*
|
||||
* @param array $clientIds Array of client IDs (should be ≤ 1000 from chunking)
|
||||
* @return Collection Aging data keyed by client_id
|
||||
*/
|
||||
private function getAgingDataOptimized(array $clientIds): Collection
|
||||
{
|
||||
if (empty($clientIds)) {
|
||||
return collect([]);
|
||||
}
|
||||
|
||||
$now = now()->startOfDay();
|
||||
$nowStr = $now->toDateString();
|
||||
$date_30 = $now->copy()->subDays(30)->toDateString();
|
||||
$date_31 = $now->copy()->subDays(31)->toDateString();
|
||||
$date_60 = $now->copy()->subDays(60)->toDateString();
|
||||
$date_61 = $now->copy()->subDays(61)->toDateString();
|
||||
$date_90 = $now->copy()->subDays(90)->toDateString();
|
||||
$date_91 = $now->copy()->subDays(91)->toDateString();
|
||||
$date_120 = $now->copy()->subDays(120)->toDateString();
|
||||
$date_121 = $now->copy()->subDays(121)->toDateString();
|
||||
$pastDate = $now->copy()->subYears(20)->toDateString();
|
||||
|
||||
return DB::table('invoices')
|
||||
->selectRaw('
|
||||
client_id,
|
||||
SUM(CASE
|
||||
WHEN (due_date > ? OR due_date IS NULL)
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as current,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_30,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_60,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_90,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_120,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_120_plus,
|
||||
SUM(balance) as total
|
||||
', [
|
||||
$nowStr, // current > now
|
||||
$date_30, $nowStr, // 0-30 days
|
||||
$date_60, $date_31, // 31-60 days
|
||||
$date_90, $date_61, // 61-90 days
|
||||
$date_120, $date_91, // 91-120 days
|
||||
$pastDate, $date_121, // 120+ days
|
||||
])
|
||||
->where('company_id', $this->company->id)
|
||||
->where('is_deleted', 0)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->whereIn('client_id', $clientIds)
|
||||
->groupBy('client_id')
|
||||
->get()
|
||||
->keyBy('client_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build row using pre-fetched aging data (optimized).
|
||||
*/
|
||||
private function buildRowOptimized(Client $client, Collection $agingData): array
|
||||
{
|
||||
$data = $agingData->get($client->id);
|
||||
|
||||
// If no invoices for this client, use zeros
|
||||
if (!$data) {
|
||||
$row = [
|
||||
$client->present()->name(),
|
||||
$client->number,
|
||||
$client->id_number,
|
||||
Number::formatMoney(0, $this->company),
|
||||
Number::formatMoney(0, $this->company),
|
||||
Number::formatMoney(0, $this->company),
|
||||
Number::formatMoney(0, $this->company),
|
||||
Number::formatMoney(0, $this->company),
|
||||
Number::formatMoney(0, $this->company),
|
||||
Number::formatMoney(0, $this->company),
|
||||
];
|
||||
} else {
|
||||
$row = [
|
||||
$client->present()->name(),
|
||||
$client->number,
|
||||
$client->id_number,
|
||||
Number::formatMoney($data->current, $this->company),
|
||||
Number::formatMoney($data->age_30, $this->company),
|
||||
Number::formatMoney($data->age_60, $this->company),
|
||||
Number::formatMoney($data->age_90, $this->company),
|
||||
Number::formatMoney($data->age_120, $this->company),
|
||||
Number::formatMoney($data->age_120_plus, $this->company),
|
||||
Number::formatMoney($data->total, $this->company),
|
||||
];
|
||||
}
|
||||
|
||||
$this->clients[] = $row;
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
private function buildRow(Client $client): array
|
||||
{
|
||||
$this->client = $client;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use League\Csv\Writer;
|
|||
use App\Models\Company;
|
||||
use App\Models\Invoice;
|
||||
use App\Libraries\MultiDB;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Export\CSV\BaseExport;
|
||||
use App\Utils\Traits\MakesDates;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
|
@ -36,10 +37,18 @@ class ClientBalanceReport extends BaseExport
|
|||
|
||||
public string $date_key = 'created_at';
|
||||
|
||||
/**
|
||||
* Toggle between optimized and legacy implementation
|
||||
* Set to false to rollback to legacy per-client queries
|
||||
*/
|
||||
private bool $useOptimizedQuery = true;
|
||||
|
||||
private string $template = '/views/templates/reports/client_balance_report.html';
|
||||
|
||||
private array $clients = [];
|
||||
|
||||
private array $invoiceData = [];
|
||||
|
||||
public array $report_keys = [
|
||||
'client_name',
|
||||
'client_number',
|
||||
|
|
@ -88,22 +97,120 @@ class ClientBalanceReport extends BaseExport
|
|||
|
||||
$this->csv->insertOne($this->buildHeader());
|
||||
|
||||
if ($this->useOptimizedQuery) {
|
||||
return $this->runOptimized();
|
||||
}
|
||||
|
||||
return $this->runLegacy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized implementation: Single query for all invoice aggregates
|
||||
* Reduces N+1 queries to 1 query total
|
||||
*/
|
||||
private function runOptimized(): string
|
||||
{
|
||||
// Fetch all clients
|
||||
$query = Client::query()
|
||||
->where('company_id', $this->company->id)
|
||||
->where('is_deleted', 0);
|
||||
|
||||
$query = $this->filterByUserPermissions($query);
|
||||
|
||||
$query->orderBy('balance', 'desc')
|
||||
$clients = $query->orderBy('balance', 'desc')->get();
|
||||
|
||||
// Fetch all invoice aggregates in a single query
|
||||
$this->invoiceData = $this->getInvoiceDataOptimized($clients->pluck('id')->toArray());
|
||||
|
||||
// Build rows using pre-fetched data
|
||||
foreach ($clients as $client) {
|
||||
/** @var \App\Models\Client $client */
|
||||
$this->csv->insertOne($this->buildRowOptimized($client));
|
||||
}
|
||||
|
||||
return $this->csv->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy implementation: Preserved for rollback
|
||||
* Makes 2 queries per client (count + sum)
|
||||
*/
|
||||
private function runLegacy(): string
|
||||
{
|
||||
$query = Client::query()
|
||||
->where('company_id', $this->company->id)
|
||||
->where('is_deleted', 0);
|
||||
|
||||
$query = $this->filterByUserPermissions($query);
|
||||
|
||||
$query->where('balance', '!=', 0)
|
||||
->orderBy('balance', 'desc')
|
||||
->cursor()
|
||||
->each(function ($client) {
|
||||
/** @var \App\Models\Client $client */
|
||||
$this->csv->insertOne($this->buildRow($client));
|
||||
|
||||
});
|
||||
|
||||
return $this->csv->toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch invoice aggregates for all clients in a single query
|
||||
*/
|
||||
private function getInvoiceDataOptimized(array $clientIds): array
|
||||
{
|
||||
if (empty($clientIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Build base query
|
||||
$query = Invoice::query()
|
||||
->select('client_id')
|
||||
->selectRaw('COUNT(*) as invoice_count')
|
||||
->selectRaw('SUM(balance) as total_balance')
|
||||
->where('company_id', $this->company->id)
|
||||
->whereIn('client_id', $clientIds)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('is_deleted', 0)
|
||||
->groupBy('client_id');
|
||||
|
||||
// Apply date filtering using the same logic as legacy
|
||||
$query = $this->addDateRange($query, 'invoices');
|
||||
|
||||
// Execute and index by client_id
|
||||
$results = $query->get();
|
||||
|
||||
$data = [];
|
||||
foreach ($results as $row) {
|
||||
$data[$row->client_id] = [ // @phpstan-ignore-line
|
||||
'count' => $row->invoice_count, // @phpstan-ignore-line
|
||||
'balance' => $row->total_balance ?? 0,
|
||||
];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build row using pre-fetched invoice data (optimized path)
|
||||
*/
|
||||
private function buildRowOptimized(Client $client): array
|
||||
{
|
||||
$invoiceData = $this->invoiceData[$client->id] ?? ['count' => 0, 'balance' => 0];
|
||||
|
||||
$item = [
|
||||
$client->present()->name(),
|
||||
$client->number,
|
||||
$client->id_number,
|
||||
$invoiceData['count'],
|
||||
$invoiceData['balance'],
|
||||
Number::formatMoney($client->credit_balance, $this->company),
|
||||
Number::formatMoney($client->payment_balance, $this->company),
|
||||
];
|
||||
|
||||
$this->clients[] = $item;
|
||||
|
||||
return $item;
|
||||
}
|
||||
|
||||
public function buildHeader(): array
|
||||
|
|
@ -143,6 +250,10 @@ class ClientBalanceReport extends BaseExport
|
|||
return $ts_instance->getPdf();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy row builder: Preserved for rollback
|
||||
* Makes 2 queries per client
|
||||
*/
|
||||
private function buildRow(Client $client): array
|
||||
{
|
||||
$query = Invoice::query()->where('client_id', $client->id)
|
||||
|
|
|
|||
|
|
@ -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.35'),
|
||||
'app_tag' => env('APP_TAG', '5.12.35'),
|
||||
'app_version' => env('APP_VERSION', '5.12.36'),
|
||||
'app_tag' => env('APP_TAG', '5.12.36'),
|
||||
'minimum_client_version' => '5.0.16',
|
||||
'terms_version' => '1.0.1',
|
||||
'api_secret' => env('API_SECRET', false),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateInvoicesIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateInvoicesIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'invoices_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core invoice fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateQuotesIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateQuotesIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'quotes_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core quote fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateCreditsIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateCreditsIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'credits_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core credit fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateRecurringInvoicesIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,9 +14,11 @@ final class CreateRecurringInvoicesIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Force drop any existing indices to avoid mapping conflicts
|
||||
Index::dropIfExists('recurring_invoices_v2');
|
||||
Index::dropIfExists('recurring_invoices');
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'recurring_invoices_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreatePurchaseOrdersIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreatePurchaseOrdersIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'purchase_orders_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core purchase order fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateVendorsIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateVendorsIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'vendors_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core vendor fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateExpensesIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateExpensesIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'expenses_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core expense fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateProjectsIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateProjectsIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'projects_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core project fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateTasksIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateTasksIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'tasks_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core task fields
|
||||
|
|
@ -68,4 +75,3 @@ final class CreateTasksIndex implements MigrationInterface
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateClientContactsIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateClientContactsIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'client_contacts_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core client contact fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateVendorContactsIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateVendorContactsIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'vendor_contacts_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core vendor contact fields
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use Elastic\Adapter\Indices\Mapping;
|
|||
use Elastic\Adapter\Indices\Settings;
|
||||
use Elastic\Migrations\Facades\Index;
|
||||
use Elastic\Migrations\MigrationInterface;
|
||||
use Elastic\Elasticsearch\ClientBuilder;
|
||||
|
||||
final class CreateClientsIndex implements MigrationInterface
|
||||
{
|
||||
|
|
@ -13,6 +14,12 @@ final class CreateClientsIndex implements MigrationInterface
|
|||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Check if index already exists (idempotency)
|
||||
$client = ClientBuilder::fromConfig(config('elastic.client.connections.default'));
|
||||
if ($client->indices()->exists(['index' => 'clients_v2'])) {
|
||||
return; // Index already exists, skip creation
|
||||
}
|
||||
|
||||
$mapping = [
|
||||
'properties' => [
|
||||
// Core client fields
|
||||
|
|
@ -143,4 +150,3 @@ final class CreateClientsIndex implements MigrationInterface
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ parameters:
|
|||
- 'app/Utils/Traits/*'
|
||||
- 'Modules/Accounting/*'
|
||||
- 'tests/*'
|
||||
- '~/.cursor/*'
|
||||
universalObjectCratesClasses:
|
||||
- App\DataMapper\Tax\RuleInterface
|
||||
- App\DataMapper\FeesAndLimits
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
var h=Object.defineProperty;var y=(i,e,t)=>e in i?h(i,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):i[e]=t;var u=(i,e,t)=>(y(i,typeof e!="symbol"?e+"":e,t),t);import{i as p,w}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 b{constructor(){u(this,"startTimer",()=>{});this.copyToClipboard=this.copyToClipboard.bind(this),this.refreshBTCPrice=this.refreshBTCPrice.bind(this),this.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode.bind(this),this.startTimer=this.startTimer.bind(this)}copyToClipboard(e,t,n){const o=n?t.nextElementSibling:t,c=o.src,s=document.createElement("input"),r=document.getElementById(e),{value:a,innerText:d}=r||{},l=a||d;s.value=l,document.body.appendChild(s),s.select(),document.execCommand("copy"),document.body.removeChild(s),o.src="data:image/svg+xml;base64,"+btoa(`
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.04706 14C4.04706 8.55609 8.46025 4.1429 13.9042 4.1429C19.3482 4.1429 23.7613 8.55609 23.7613 14C23.7613 19.444 19.3482 23.8572 13.9042 23.8572C8.46025 23.8572 4.04706 19.444 4.04706 14Z" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.52325 14L12.809 17.2858L18.2852 11.8096" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`),setTimeout(()=>{o.src=c},5e3)}async fetchAndDisplayQRCode(){}async refreshBTCPrice(){const e=document.querySelector(".icon-refresh");e.classList.add("rotating"),document.getElementsByClassName("btc-value")[0].innerHTML="Refreshing...";const t=async()=>{try{const n=document.querySelector('meta[name="currency"]').content,o=await fetch(`/api/v1/get-btc-price?currency=${n}`);if(!o.ok)throw new Error("Network response was not ok");return(await o.json()).price}catch(n){console.error("There was a problem with the BTC price fetch operation:",n)}};try{const n=await t();if(n){const o=document.querySelector('meta[name="currency"]').content;document.getElementsByClassName("btc-value")[0].innerHTML="1 BTC = "+(n||"N/A")+" "+o+", updates in <span id='countdown'></span>";const c=(document.querySelector('meta[name="amount"]').content/n).toFixed(10);document.querySelector('input[name="btc_price"]').value=n,document.querySelector('input[name="btc_amount"]').value=c,document.getElementById("btc-amount").textContent=c;const s=document.querySelector('meta[name="btc_address"]').content,r=document.getElementById("qr-code-link"),a=document.getElementById("open-in-wallet-link");r.href=`bitcoin:${s}?amount=${c}`,a.href=`bitcoin:${s}?amount=${c}`,await this.fetchAndDisplayQRCode(c),this.startTimer(600)}}finally{e.classList.remove("rotating")}}handle(){window.copyToClipboard=this.copyToClipboard,window.refreshBTCPrice=this.refreshBTCPrice,window.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode,window.startTimer=this.startTimer;const e=()=>{const t=document.querySelector('meta[name="btc_address"]').content,n=`wss://www.blockonomics.co/payment/${t}`,o=new WebSocket(n);o.onmessage=function(c){const s=JSON.parse(c.data),{status:r,txid:a,value:d}=s||{};console.log("Payment status:",r),(r===0||r===1||r===2)&&(document.querySelector('input[name="txid"]').value=a||"",document.querySelector('input[name="status"]').value=r||"",document.querySelector('input[name="btc_amount"]').value=d||"",document.querySelector('input[name="btc_address"]').value=t||"",document.getElementById("server-response").submit())}};startTimer(600),e(),fetchAndDisplayQRCode()}}function m(){new b().handle(),window.bootBlockonomics=m}p()?m():w("#blockonomics-payment").then(()=>m());
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
var h=Object.defineProperty;var y=(a,t,e)=>t in a?h(a,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):a[t]=e;var l=(a,t,e)=>(y(a,typeof t!="symbol"?t+"":t,e),e);import{i as w,w as p}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 f{constructor(){l(this,"startTimer",t=>{const e=new Date().getTime()+t*1e3;document.getElementById("countdown").innerHTML="10:00 min";const o=()=>{const c=new Date().getTime(),n=e-c;if(document.getElementsByClassName("btc-value")[0].innerHTML.includes("Refreshing"))return;if(n<0){refreshBTCPrice();return}const s=Math.floor(n%(1e3*60*60)/(1e3*60)),i=Math.floor(n%(1e3*60)/1e3),d=String(s).padStart(2,"0"),m=String(i).padStart(2,"0");document.getElementById("countdown").innerHTML=d+":"+m+" min"};clearInterval(window.countdownInterval),window.countdownInterval=setInterval(o,1e3)});this.copyToClipboard=this.copyToClipboard.bind(this),this.refreshBTCPrice=this.refreshBTCPrice.bind(this),this.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode.bind(this),this.startTimer=this.startTimer.bind(this)}copyToClipboard(t,e,o){const c=o?e.nextElementSibling:e,n=c.src,r=document.createElement("input"),s=document.getElementById(t),{value:i,innerText:d}=s||{},m=i||d;r.value=m,document.body.appendChild(r),r.select(),document.execCommand("copy"),document.body.removeChild(r),c.src="data:image/svg+xml;base64,"+btoa(`
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.04706 14C4.04706 8.55609 8.46025 4.1429 13.9042 4.1429C19.3482 4.1429 23.7613 8.55609 23.7613 14C23.7613 19.444 19.3482 23.8572 13.9042 23.8572C8.46025 23.8572 4.04706 19.444 4.04706 14Z" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M9.52325 14L12.809 17.2858L18.2852 11.8096" stroke="#000" stroke-width="2.19048" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`),setTimeout(()=>{c.src=n},5e3)}async fetchAndDisplayQRCode(t=null){try{const e=document.querySelector('meta[name="btc_address"]').content,c=encodeURIComponent(`bitcoin:${e}?amount=${t||"{{$btc_amount}}"}`),n=await fetch(`/api/v1/get-blockonomics-qr-code?qr_string=${c}`);if(!n.ok)throw new Error(`HTTP error! status: ${n.status}`);const r=await n.text();document.getElementById("qrcode-container").innerHTML=r}catch(e){console.error("Error fetching QR code:",e),document.getElementById("qrcode-container").textContent="Error loading QR code"}}async refreshBTCPrice(){const t=document.querySelector(".icon-refresh");t.classList.add("rotating"),document.getElementsByClassName("btc-value")[0].innerHTML="Refreshing...";const e=async()=>{try{const o=document.querySelector('meta[name="currency"]').content,c=await fetch(`/api/v1/get-btc-price?currency=${o}`);if(!c.ok)throw new Error("Network response was not ok");return(await c.json()).price}catch(o){console.error("There was a problem with the BTC price fetch operation:",o)}};try{const o=await e();if(o){const c=document.querySelector('meta[name="currency"]').content;document.getElementsByClassName("btc-value")[0].innerHTML="1 BTC = "+(o||"N/A")+" "+c+", updates in <span id='countdown'></span>";const n=(document.querySelector('meta[name="amount"]').content/o).toFixed(10);document.querySelector('input[name="btc_price"]').value=o,document.querySelector('input[name="btc_amount"]').value=n,document.getElementById("btc-amount").textContent=n;const r=document.querySelector('meta[name="btc_address"]').content,s=document.getElementById("qr-code-link"),i=document.getElementById("open-in-wallet-link");s.href=`bitcoin:${r}?amount=${n}`,i.href=`bitcoin:${r}?amount=${n}`,await this.fetchAndDisplayQRCode(n),this.startTimer(600)}}finally{t.classList.remove("rotating")}}handle(){window.copyToClipboard=this.copyToClipboard,window.refreshBTCPrice=this.refreshBTCPrice,window.fetchAndDisplayQRCode=this.fetchAndDisplayQRCode,window.startTimer=this.startTimer;const t=()=>{const e=document.querySelector('meta[name="btc_address"]').content,o=`wss://www.blockonomics.co/payment/${e}`,c=new WebSocket(o);c.onmessage=function(n){const r=JSON.parse(n.data),{status:s,txid:i,value:d}=r||{};console.log("Payment status:",s),(s===0||s===1||s===2)&&(document.querySelector('input[name="txid"]').value=i||"",document.querySelector('input[name="status"]').value=s||"",document.querySelector('input[name="btc_amount"]').value=d||"",document.querySelector('input[name="btc_address"]').value=e||"",document.getElementById("server-response").submit())}};startTimer(600),t(),fetchAndDisplayQRCode()}}function u(){new f().handle(),window.bootBlockonomics=u}w()?u():p("#blockonomics-payment").then(()=>u());
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
"src": "resources/js/clients/payments/authorize-credit-card-payment.js"
|
||||
},
|
||||
"resources/js/clients/payments/blockonomics.js": {
|
||||
"file": "assets/blockonomics-c3966bec.js",
|
||||
"file": "assets/blockonomics-bab011a6.js",
|
||||
"imports": [
|
||||
"_wait-8f4ae121.js"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -50,50 +50,14 @@ class Blockonomics {
|
|||
}
|
||||
|
||||
|
||||
async fetchAndDisplayQRCode (newBtcAmount = null) {
|
||||
try {
|
||||
const btcAddress = document.querySelector('meta[name="btc_address"]').content;
|
||||
const btcAmount = newBtcAmount || '{{$btc_amount}}';
|
||||
const qrString = encodeURIComponent(`bitcoin:${btcAddress}?amount=${btcAmount}`);
|
||||
const response = await fetch(`/api/v1/get-blockonomics-qr-code?qr_string=${qrString}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const svgText = await response.text();
|
||||
document.getElementById('qrcode-container').innerHTML = svgText;
|
||||
} catch (error) {
|
||||
console.error('Error fetching QR code:', error);
|
||||
document.getElementById('qrcode-container').textContent = 'Error loading QR code';
|
||||
}
|
||||
// QR code fetching is now handled by Livewire component (BlockonomicsQRCode)
|
||||
async fetchAndDisplayQRCode () {
|
||||
// This method is deprecated - use Livewire component instead
|
||||
};
|
||||
|
||||
startTimer = (seconds) => {
|
||||
const countDownDate = new Date().getTime() + seconds * 1000;
|
||||
document.getElementById("countdown").innerHTML = "10" + ":" + "00" + " min";
|
||||
|
||||
const updateCountdown = () => {
|
||||
const now = new Date().getTime();
|
||||
const distance = countDownDate - now;
|
||||
|
||||
const isRefreshing = document.getElementsByClassName("btc-value")[0].innerHTML.includes("Refreshing");
|
||||
if (isRefreshing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (distance < 0) {
|
||||
refreshBTCPrice();
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const formattedSeconds = String(seconds).padStart(2, '0');
|
||||
document.getElementById("countdown").innerHTML = formattedMinutes + ":" + formattedSeconds + " min";
|
||||
}
|
||||
|
||||
clearInterval(window.countdownInterval);
|
||||
window.countdownInterval = setInterval(updateCountdown, 1000);
|
||||
// Countdown timer is now handled by Livewire component (BlockonomicsPriceDisplay)
|
||||
startTimer = () => {
|
||||
// This method is deprecated - use Livewire component instead
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
<div class="btc-value-wrapper">
|
||||
<div class="btc-value">
|
||||
1 BTC = {{ $btc_price }} {{ $currency }}, updates in <span id="countdown-livewire">{{ $countdown }}</span>
|
||||
</div>
|
||||
<span class="icon-refresh {{ $is_refreshing ? 'rotating' : '' }}" wire:click="refreshBTCPrice"
|
||||
{{ $is_refreshing ? 'style="pointer-events: none;"' : '' }}></span>
|
||||
|
||||
<style>
|
||||
.btc-value-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.btc-value {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon-refresh {
|
||||
cursor: pointer;
|
||||
margin-left: 5px;
|
||||
width: 28px;
|
||||
display: flex;
|
||||
font-size: 32px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.rotating {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@script
|
||||
<script>
|
||||
let countdownInterval = null;
|
||||
let countdownDate = null;
|
||||
|
||||
const updateCountdown = () => {
|
||||
if (!countdownDate) return;
|
||||
|
||||
const now = new Date().getTime();
|
||||
const distance = countdownDate - now;
|
||||
|
||||
if (distance <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
$wire.refreshBTCPrice();
|
||||
return;
|
||||
}
|
||||
|
||||
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||
|
||||
if (!isNaN(minutes) && !isNaN(seconds)) {
|
||||
const formattedMinutes = String(minutes).padStart(2, '0');
|
||||
const formattedSeconds = String(seconds).padStart(2, '0');
|
||||
|
||||
const countdownElement = document.getElementById('countdown-livewire');
|
||||
if (countdownElement) {
|
||||
countdownElement.textContent = formattedMinutes + ':' + formattedSeconds;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startCountdownTimer = ({ duration }) => {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
countdownDate = new Date().getTime() + duration * 1000;
|
||||
updateCountdown();
|
||||
countdownInterval = setInterval(updateCountdown, 1000);
|
||||
};
|
||||
|
||||
$wire.on('start-countdown', startCountdownTimer);
|
||||
|
||||
// Listen for updates after price refresh
|
||||
$wire.on('btc-price-updated', () => {
|
||||
startCountdownTimer({ duration: 600 });
|
||||
});
|
||||
|
||||
// Initialize countdown on component mount with a small delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
startCountdownTimer({ duration: 600 });
|
||||
}, 100);
|
||||
</script>
|
||||
@endscript
|
||||
</div>
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<div class="qrcode-wrapper">
|
||||
@if ($error_message)
|
||||
<div class="error-message">{{ $error_message }}</div>
|
||||
@elseif ($is_loading)
|
||||
<div class="loading-message">Loading QR code...</div>
|
||||
@else
|
||||
<a href="bitcoin:{{ $btc_address }}?amount={{ $btc_amount }}" id="qr-code-link" target="_blank">
|
||||
<div id="qrcode-container">
|
||||
{!! $qr_code_svg !!}
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<style>
|
||||
.qrcode-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #d32f2f;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: #666;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#qrcode-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
|
|
@ -25,8 +25,8 @@
|
|||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@if($key)
|
||||
window.history.pushState({}, "", "{{ url("client/credit/{$key}") }}");
|
||||
@if($_key)
|
||||
window.history.pushState({}, "", "{{ url("client/credit/{$_key}") }}");
|
||||
@endif
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@
|
|||
<div class="scan-section">
|
||||
<div class="title">Scan</div>
|
||||
<span class="input-wrapper">
|
||||
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" id="qr-code-link" target="_blank">
|
||||
<div id="qrcode-container"></div>
|
||||
</a>
|
||||
<livewire:billing-portal.payments.blockonomics-qr-code
|
||||
:btc_address="$btc_address"
|
||||
:btc_amount="$btc_amount"
|
||||
/>
|
||||
</span>
|
||||
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" target="_blank" id="open-in-wallet-link">Open in Wallet</a>
|
||||
</div>
|
||||
|
|
@ -40,10 +41,11 @@
|
|||
</div>
|
||||
<img onclick='copyToClipboard("btc-amount", this)' src="{{ 'data:image/svg+xml;base64,' . base64_encode('<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg" ><path d="M15.5 1H3.5C2.4 1 1.5 1.9 1.5 3V17H3.5V3H15.5V1ZM18.5 5H7.5C6.4 5 5.5 5.9 5.5 7V21C5.5 22.1 6.4 23 7.5 23H18.5C19.6 23 20.5 22.1 20.5 21V7C20.5 5.9 19.6 5 18.5 5ZM18.5 21H7.5V7H18.5V21Z" fill="#000"/></svg>') }}" class="icon" alt="Copy Icon">
|
||||
</span>
|
||||
<div class="btc-value-wrapper">
|
||||
<div class="btc-value">1 BTC = {{$btc_price}} {{$currency}}, updates in <span id="countdown"></span></div>
|
||||
<span class="icon-refresh" onclick='refreshBTCPrice()'></span>
|
||||
</div>
|
||||
<livewire:billing-portal.payments.blockonomics-price-display
|
||||
:currency="$currency"
|
||||
:btc_price="$btc_price"
|
||||
:btc_amount="$btc_amount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@
|
|||
<div class="scan-section">
|
||||
<div class="title">Scan</div>
|
||||
<span class="input-wrapper">
|
||||
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" id="qr-code-link" target="_blank">
|
||||
<div id="qrcode-container"></div>
|
||||
</a>
|
||||
<livewire:billing-portal.payments.blockonomics-qr-code
|
||||
:btc_address="$btc_address"
|
||||
:btc_amount="$btc_amount"
|
||||
/>
|
||||
</span>
|
||||
<a href="bitcoin:{{$btc_address}}?amount={{$btc_amount}}" target="_blank" id="open-in-wallet-link">Open in Wallet</a>
|
||||
</div>
|
||||
|
|
@ -39,10 +40,11 @@
|
|||
</div>
|
||||
<img onclick='copyToClipboard("btc-amount", this)' src="{{ 'data:image/svg+xml;base64,' . base64_encode('<svg width="22" height="24" viewBox="0 0 22 24" fill="none" xmlns="http://www.w3.org/2000/svg" ><path d="M15.5 1H3.5C2.4 1 1.5 1.9 1.5 3V17H3.5V3H15.5V1ZM18.5 5H7.5C6.4 5 5.5 5.9 5.5 7V21C5.5 22.1 6.4 23 7.5 23H18.5C19.6 23 20.5 22.1 20.5 21V7C20.5 5.9 19.6 5 18.5 5ZM18.5 21H7.5V7H18.5V21Z" fill="#000"/></svg>') }}" class="icon" alt="Copy Icon">
|
||||
</span>
|
||||
<div class="btc-value-wrapper">
|
||||
<div class="btc-value">1 BTC = {{$btc_price}} {{$currency}}, updates in <span id="countdown"></span></div>
|
||||
<span class="icon-refresh" onclick='refreshBTCPrice()'></span>
|
||||
</div>
|
||||
<livewire:billing-portal.payments.blockonomics-price-display
|
||||
:currency="$currency"
|
||||
:btc_price="$btc_price"
|
||||
:btc_amount="$btc_amount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,8 +110,8 @@
|
|||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@if($key)
|
||||
window.history.pushState({}, "", "{{ url("client/invoice/{$key}") }}");
|
||||
@if($_key)
|
||||
window.history.pushState({}, "", "{{ url("client/invoice/{$_key}") }}");
|
||||
@endif
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,8 +53,8 @@
|
|||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@if($key)
|
||||
window.history.pushState({}, "", "{{ url("vendor/purchase_order/{$key}") }}");
|
||||
@if($_key)
|
||||
window.history.pushState({}, "", "{{ url("vendor/purchase_order/{$_key}") }}");
|
||||
@endif
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,8 +89,8 @@
|
|||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@if($key)
|
||||
window.history.pushState({}, "", "{{ url("client/quote/{$key}") }}");
|
||||
@if($_key)
|
||||
window.history.pushState({}, "", "{{ url("client/quote/{$_key}") }}");
|
||||
@endif
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,32 @@ class ClientGatewayTokenApiTest extends TestCase
|
|||
}
|
||||
|
||||
|
||||
public function testCompanyGatewaySettableOnTokenAndDefaultIsTrue()
|
||||
{
|
||||
|
||||
$data = [
|
||||
'client_id' => $this->client->hashed_id,
|
||||
'company_gateway_id' => $this->cg->hashed_id,
|
||||
'gateway_type_id' => GatewayType::CREDIT_CARD,
|
||||
'token' => 'tokey',
|
||||
'gateway_customer_reference' => 'reffy',
|
||||
'meta' => '{}',
|
||||
'is_default' => true,
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->postJson('/api/v1/client_gateway_tokens', $data);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$arr = $response->json();
|
||||
|
||||
$t1 = $arr['data']['id'];
|
||||
|
||||
$this->assertTrue($arr['data']['is_default']);
|
||||
}
|
||||
|
||||
public function testCompanyGatewaySettableOnToken()
|
||||
{
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,85 @@ class CreditTest extends TestCase
|
|||
$this->makeTestData();
|
||||
}
|
||||
|
||||
|
||||
public function testClientPaidToDateStateAfterCreditCreatedForPaidInvoice()
|
||||
{
|
||||
$c = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'balance' => 0,
|
||||
'paid_to_date' => 0,
|
||||
]);
|
||||
|
||||
$ii = new InvoiceItem();
|
||||
$ii->cost = 100;
|
||||
$ii->quantity = 1;
|
||||
$ii->product_key = 'xx';
|
||||
$ii->notes = 'yy';
|
||||
|
||||
$i = \App\Models\Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $c->id,
|
||||
'tax_name1' => '',
|
||||
'tax_name2' => '',
|
||||
'tax_name3' => '',
|
||||
'tax_rate1' => 0,
|
||||
'tax_rate2' => 0,
|
||||
'tax_rate3' => 0,
|
||||
'discount' => 0,
|
||||
'line_items' => [
|
||||
$ii
|
||||
],
|
||||
'status_id' => 1,
|
||||
]);
|
||||
|
||||
$repo = new InvoiceRepository();
|
||||
$repo->save([], $i);
|
||||
|
||||
$i = $i->calc()->getInvoice();
|
||||
$i = $i->service()->markPaid()->save(); //paid
|
||||
|
||||
$payment = $i->payments()->first();
|
||||
|
||||
$this->assertNotNull($payment);
|
||||
|
||||
$this->assertEquals(0, $i->balance);
|
||||
$this->assertEquals(100, $i->amount);
|
||||
|
||||
$credit_array = $i->withoutRelations()->toArray();
|
||||
$credit_array['invoice_id'] = $i->hashed_id;
|
||||
$credit_array['client_id'] = $c->hashed_id;
|
||||
unset($credit_array['backup']);
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->post('/api/v1/credits', $credit_array);
|
||||
|
||||
$response->assertStatus(200); //reversal - credit created.
|
||||
|
||||
$arr = $response->json();
|
||||
$credit = \App\Models\Credit::find($this->decodePrimaryKey($arr['data']['id']));
|
||||
|
||||
$this->assertNotNull($credit);
|
||||
$payment = $payment->fresh();
|
||||
|
||||
$i = $i->fresh();
|
||||
|
||||
$this->assertEquals(\App\Models\Invoice::STATUS_REVERSED, $i->status_id);
|
||||
// $this->assertTrue($payment->credits()->exists());
|
||||
|
||||
$client = $i->client;
|
||||
|
||||
$this->assertEquals(100, $client->credit_balance);
|
||||
$this->assertEquals(0, $client->paid_to_date);
|
||||
$this->assertEquals(0, $client->balance);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
public function testNewCreditDeletionAfterInvoiceReversalAndPaymentRefund()
|
||||
{
|
||||
$c = Client::factory()->create([
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ class TaskApiTest extends TestCase
|
|||
Model::reguard();
|
||||
}
|
||||
|
||||
private function checkTimeLog(array $log): bool
|
||||
private function checkTimeLog(array $log)
|
||||
{
|
||||
if (count($log) == 0) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,30 @@ class TaskApiValidationTest extends TestCase
|
|||
]);
|
||||
}
|
||||
|
||||
public function testTimeLogValidation()
|
||||
{
|
||||
$data = [
|
||||
'client_id' => $this->testClient->hashed_id,
|
||||
'description' => 'Test Task Description',
|
||||
'time_log' => json_encode([
|
||||
[
|
||||
"billable" => true,
|
||||
"date" => "2025-10-31",
|
||||
"end_time" => "16:00:00",
|
||||
"start_time" => "08:00:00"
|
||||
]
|
||||
]),
|
||||
];
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->postJson("/api/v1/tasks", $data);
|
||||
|
||||
$response->assertStatus(422);
|
||||
nlog($response->json());
|
||||
|
||||
}
|
||||
// ==================== VALID PAYLOADS (200 STATUS) ====================
|
||||
|
||||
public function testCreateTaskWithValidPayloadReturns200()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,594 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use App\Models\Client;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Company;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Test AR Summary Report optimization strategy.
|
||||
*
|
||||
* This test compares the current N+1 query approach (6 queries per client)
|
||||
* against an optimized single-query approach using CASE statements.
|
||||
*/
|
||||
class ARSummaryReportOptimizationTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions, MakesHash;
|
||||
|
||||
protected Company $company;
|
||||
protected User $user;
|
||||
protected array $testClients = [];
|
||||
protected array $testInvoices = [];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$account = Account::factory()->create();
|
||||
|
||||
$this->company = Company::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test data: clients with invoices in various aging buckets.
|
||||
*/
|
||||
private function createTestData(int $clientCount = 10): void
|
||||
{
|
||||
$now = now()->startOfDay();
|
||||
|
||||
for ($i = 0; $i < $clientCount; $i++) {
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'name' => "Test Client {$i}",
|
||||
'number' => "CLI-{$i}",
|
||||
]);
|
||||
|
||||
$this->testClients[] = $client;
|
||||
|
||||
// Create invoices in different aging buckets
|
||||
$invoiceScenarios = [
|
||||
// Current (due in future or no due date)
|
||||
['balance' => 100, 'due_date' => $now->copy()->addDays(10), 'status' => Invoice::STATUS_SENT],
|
||||
['balance' => 50, 'due_date' => null, 'status' => Invoice::STATUS_SENT],
|
||||
|
||||
// 0-30 days overdue
|
||||
['balance' => 200, 'due_date' => $now->copy()->subDays(15), 'status' => Invoice::STATUS_SENT],
|
||||
['balance' => 150, 'due_date' => $now->copy()->subDays(25), 'status' => Invoice::STATUS_PARTIAL],
|
||||
|
||||
// 31-60 days overdue
|
||||
['balance' => 300, 'due_date' => $now->copy()->subDays(45), 'status' => Invoice::STATUS_SENT],
|
||||
|
||||
// 61-90 days overdue
|
||||
['balance' => 400, 'due_date' => $now->copy()->subDays(75), 'status' => Invoice::STATUS_SENT],
|
||||
|
||||
// 91-120 days overdue
|
||||
['balance' => 500, 'due_date' => $now->copy()->subDays(105), 'status' => Invoice::STATUS_SENT],
|
||||
|
||||
// 120+ days overdue
|
||||
['balance' => 600, 'due_date' => $now->copy()->subDays(150), 'status' => Invoice::STATUS_SENT],
|
||||
['balance' => 700, 'due_date' => $now->copy()->subDays(365), 'status' => Invoice::STATUS_PARTIAL],
|
||||
];
|
||||
|
||||
foreach ($invoiceScenarios as $scenario) {
|
||||
$invoice = Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => $scenario['status'],
|
||||
'balance' => $scenario['balance'],
|
||||
'amount' => $scenario['balance'],
|
||||
'due_date' => $scenario['due_date'],
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
$this->testInvoices[] = $invoice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Current implementation: N+1 query approach (6 queries per client).
|
||||
*/
|
||||
private function getCurrentImplementationResults(Client $client): array
|
||||
{
|
||||
$now = now()->startOfDay();
|
||||
|
||||
// Current invoices
|
||||
$current = Invoice::withTrashed()
|
||||
->where('client_id', $client->id)
|
||||
->where('company_id', $client->company_id)
|
||||
->where('is_deleted', 0)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->where(function ($query) use ($now) {
|
||||
$query->where('due_date', '>', $now)
|
||||
->orWhereNull('due_date');
|
||||
})
|
||||
->sum('balance');
|
||||
|
||||
// 0-30 days
|
||||
$age_30 = Invoice::withTrashed()
|
||||
->where('client_id', $client->id)
|
||||
->where('company_id', $client->company_id)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->where('is_deleted', 0)
|
||||
->whereBetween('due_date', [$now->copy()->subDays(30), $now])
|
||||
->sum('balance');
|
||||
|
||||
// 31-60 days
|
||||
$age_60 = Invoice::withTrashed()
|
||||
->where('client_id', $client->id)
|
||||
->where('company_id', $client->company_id)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->where('is_deleted', 0)
|
||||
->whereBetween('due_date', [$now->copy()->subDays(60), $now->copy()->subDays(31)])
|
||||
->sum('balance');
|
||||
|
||||
// 61-90 days
|
||||
$age_90 = Invoice::withTrashed()
|
||||
->where('client_id', $client->id)
|
||||
->where('company_id', $client->company_id)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->where('is_deleted', 0)
|
||||
->whereBetween('due_date', [$now->copy()->subDays(90), $now->copy()->subDays(61)])
|
||||
->sum('balance');
|
||||
|
||||
// 91-120 days
|
||||
$age_120 = Invoice::withTrashed()
|
||||
->where('client_id', $client->id)
|
||||
->where('company_id', $client->company_id)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->where('is_deleted', 0)
|
||||
->whereBetween('due_date', [$now->copy()->subDays(120), $now->copy()->subDays(91)])
|
||||
->sum('balance');
|
||||
|
||||
// 120+ days
|
||||
$age_120_plus = Invoice::withTrashed()
|
||||
->where('client_id', $client->id)
|
||||
->where('company_id', $client->company_id)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->where('is_deleted', 0)
|
||||
->whereBetween('due_date', [$now->copy()->subYears(20), $now->copy()->subDays(121)])
|
||||
->sum('balance');
|
||||
|
||||
return [
|
||||
'current' => $current,
|
||||
'age_30' => $age_30,
|
||||
'age_60' => $age_60,
|
||||
'age_90' => $age_90,
|
||||
'age_120' => $age_120,
|
||||
'age_120_plus' => $age_120_plus,
|
||||
'total' => $current + $age_30 + $age_60 + $age_90 + $age_120 + $age_120_plus,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimized implementation: Single query with CASE statements.
|
||||
*/
|
||||
private function getOptimizedImplementationResults(array $clientIds)
|
||||
{
|
||||
$now = now()->startOfDay();
|
||||
$nowStr = $now->toDateString();
|
||||
$date_30 = $now->copy()->subDays(30)->toDateString();
|
||||
$date_31 = $now->copy()->subDays(31)->toDateString();
|
||||
$date_60 = $now->copy()->subDays(60)->toDateString();
|
||||
$date_61 = $now->copy()->subDays(61)->toDateString();
|
||||
$date_90 = $now->copy()->subDays(90)->toDateString();
|
||||
$date_91 = $now->copy()->subDays(91)->toDateString();
|
||||
$date_120 = $now->copy()->subDays(120)->toDateString();
|
||||
$date_121 = $now->copy()->subDays(121)->toDateString();
|
||||
$pastDate = $now->copy()->subYears(20)->toDateString();
|
||||
|
||||
$results = DB::table('invoices')
|
||||
->selectRaw('
|
||||
client_id,
|
||||
SUM(CASE
|
||||
WHEN (due_date > ? OR due_date IS NULL)
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as current,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_30,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_60,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_90,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_120,
|
||||
SUM(CASE
|
||||
WHEN due_date BETWEEN ? AND ?
|
||||
THEN balance
|
||||
ELSE 0
|
||||
END) as age_120_plus,
|
||||
SUM(balance) as total
|
||||
', [
|
||||
$nowStr, // current > now
|
||||
$date_30, $nowStr, // 0-30 days
|
||||
$date_60, $date_31, // 31-60 days
|
||||
$date_90, $date_61, // 61-90 days
|
||||
$date_120, $date_91, // 91-120 days
|
||||
$pastDate, $date_121, // 120+ days
|
||||
])
|
||||
->where('company_id', $this->company->id)
|
||||
->where('is_deleted', 0)
|
||||
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
|
||||
->where('balance', '>', 0)
|
||||
->whereIn('client_id', $clientIds)
|
||||
->groupBy('client_id')
|
||||
->get()
|
||||
->keyBy('client_id');
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test data quality: verify optimized query produces same results as current.
|
||||
*/
|
||||
public function testDataQualityOptimizedMatchesCurrent()
|
||||
{
|
||||
$this->createTestData(5);
|
||||
|
||||
$clientIds = collect($this->testClients)->pluck('id')->toArray();
|
||||
$optimizedResults = $this->getOptimizedImplementationResults($clientIds);
|
||||
|
||||
foreach ($this->testClients as $client) {
|
||||
$currentResults = $this->getCurrentImplementationResults($client);
|
||||
$optimizedResult = $optimizedResults->get($client->id);
|
||||
|
||||
$this->assertNotNull($optimizedResult, "Client {$client->id} not found in optimized results");
|
||||
|
||||
// Compare each aging bucket
|
||||
$this->assertEquals(
|
||||
$currentResults['current'],
|
||||
$optimizedResult->current,
|
||||
"Current balance mismatch for client {$client->name}"
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
$currentResults['age_30'],
|
||||
$optimizedResult->age_30,
|
||||
"0-30 days balance mismatch for client {$client->name}"
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
$currentResults['age_60'],
|
||||
$optimizedResult->age_60,
|
||||
"31-60 days balance mismatch for client {$client->name}"
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
$currentResults['age_90'],
|
||||
$optimizedResult->age_90,
|
||||
"61-90 days balance mismatch for client {$client->name}"
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
$currentResults['age_120'],
|
||||
$optimizedResult->age_120,
|
||||
"91-120 days balance mismatch for client {$client->name}"
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
$currentResults['age_120_plus'],
|
||||
$optimizedResult->age_120_plus,
|
||||
"120+ days balance mismatch for client {$client->name}"
|
||||
);
|
||||
|
||||
$this->assertEquals(
|
||||
$currentResults['total'],
|
||||
$optimizedResult->total,
|
||||
"Total balance mismatch for client {$client->name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test edge case: client with no invoices.
|
||||
*/
|
||||
public function testClientWithNoInvoices()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$currentResults = $this->getCurrentImplementationResults($client);
|
||||
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||
|
||||
// Current implementation returns 0 for all buckets
|
||||
$this->assertEquals(0, $currentResults['current']);
|
||||
$this->assertEquals(0, $currentResults['total']);
|
||||
|
||||
// Optimized should either not return the client or return zeros
|
||||
$optimizedResult = $optimizedResults->get($client->id);
|
||||
if ($optimizedResult) {
|
||||
$this->assertEquals(0, $optimizedResult->total);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test edge case: invoices with status other than SENT/PARTIAL should be excluded.
|
||||
*/
|
||||
public function testExcludesNonSentPartialInvoices()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
// Create invoices with various statuses
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100,
|
||||
'due_date' => now()->subDays(10),
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_DRAFT, // Should be excluded
|
||||
'balance' => 200,
|
||||
'due_date' => now()->subDays(10),
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_PAID, // Should be excluded
|
||||
'balance' => 0,
|
||||
'due_date' => now()->subDays(10),
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
$currentResults = $this->getCurrentImplementationResults($client);
|
||||
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||
$optimizedResult = $optimizedResults->get($client->id);
|
||||
|
||||
// Should only count the SENT invoice
|
||||
$this->assertEquals(100, $currentResults['age_30']);
|
||||
$this->assertEquals(100, $optimizedResult->age_30);
|
||||
$this->assertEquals(100, $currentResults['total']);
|
||||
$this->assertEquals(100, $optimizedResult->total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test edge case: deleted invoices should be excluded.
|
||||
*/
|
||||
public function testExcludesDeletedInvoices()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100,
|
||||
'due_date' => now()->subDays(10),
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 200,
|
||||
'due_date' => now()->subDays(10),
|
||||
'is_deleted' => true, // Should be excluded
|
||||
]);
|
||||
|
||||
$currentResults = $this->getCurrentImplementationResults($client);
|
||||
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||
$optimizedResult = $optimizedResults->get($client->id);
|
||||
|
||||
$this->assertEquals(100, $currentResults['age_30']);
|
||||
$this->assertEquals(100, $optimizedResult->age_30);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test edge case: invoices with zero balance should be excluded.
|
||||
*/
|
||||
public function testExcludesZeroBalanceInvoices()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100,
|
||||
'due_date' => now()->subDays(10),
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 0, // Should be excluded
|
||||
'due_date' => now()->subDays(10),
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
|
||||
$currentResults = $this->getCurrentImplementationResults($client);
|
||||
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||
$optimizedResult = $optimizedResults->get($client->id);
|
||||
|
||||
$this->assertEquals(100, $currentResults['age_30']);
|
||||
$this->assertEquals(100, $optimizedResult->age_30);
|
||||
$this->assertEquals(100, $currentResults['total']);
|
||||
$this->assertEquals(100, $optimizedResult->total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance test: compare query count between implementations.
|
||||
*/
|
||||
public function testPerformanceQueryCount()
|
||||
{
|
||||
$this->createTestData(10);
|
||||
$clientIds = collect($this->testClients)->pluck('id')->toArray();
|
||||
|
||||
// Count queries for current implementation
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
foreach ($this->testClients as $client) {
|
||||
$this->getCurrentImplementationResults($client);
|
||||
}
|
||||
$currentQueries = DB::getQueryLog();
|
||||
// Only count invoice queries (not time zone or other queries)
|
||||
$currentQueryCount = count(array_filter($currentQueries, function($query) {
|
||||
return strpos($query['query'], 'from `invoices`') !== false;
|
||||
}));
|
||||
DB::disableQueryLog();
|
||||
|
||||
// Count queries for optimized implementation
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
$this->getOptimizedImplementationResults($clientIds);
|
||||
$optimizedQueries = DB::getQueryLog();
|
||||
// Only count invoice queries
|
||||
$optimizedQueryCount = count(array_filter($optimizedQueries, function($query) {
|
||||
return strpos($query['query'], 'from `invoices`') !== false;
|
||||
}));
|
||||
DB::disableQueryLog();
|
||||
|
||||
// Current should be 6 queries per client (6 * 10 = 60)
|
||||
$this->assertEquals(60, $currentQueryCount, 'Current implementation should execute 6 queries per client');
|
||||
|
||||
// Optimized should be 1 query total
|
||||
$this->assertEquals(1, $optimizedQueryCount, 'Optimized implementation should execute 1 query total');
|
||||
|
||||
$improvement = $currentQueryCount / $optimizedQueryCount;
|
||||
$this->assertGreaterThan(50, $improvement, 'Optimized should be at least 50x better');
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance test: measure execution time difference.
|
||||
*/
|
||||
public function testPerformanceExecutionTime()
|
||||
{
|
||||
$this->createTestData(50);
|
||||
$clientIds = collect($this->testClients)->pluck('id')->toArray();
|
||||
|
||||
// Measure current implementation time
|
||||
$currentStart = microtime(true);
|
||||
foreach ($this->testClients as $client) {
|
||||
$this->getCurrentImplementationResults($client);
|
||||
}
|
||||
$currentTime = microtime(true) - $currentStart;
|
||||
|
||||
// Measure optimized implementation time
|
||||
$optimizedStart = microtime(true);
|
||||
$this->getOptimizedImplementationResults($clientIds);
|
||||
$optimizedTime = microtime(true) - $optimizedStart;
|
||||
|
||||
// Optimized should be significantly faster
|
||||
$this->assertLessThan($currentTime, $optimizedTime, 'Optimized should be faster');
|
||||
|
||||
$speedup = $currentTime / $optimizedTime;
|
||||
dump([
|
||||
'clients' => 50,
|
||||
'current_time' => round($currentTime, 4) . 's',
|
||||
'optimized_time' => round($optimizedTime, 4) . 's',
|
||||
'speedup' => round($speedup, 2) . 'x',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test aging bucket boundaries are correct.
|
||||
*/
|
||||
public function testAgingBucketBoundaries()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$now = now()->startOfDay();
|
||||
|
||||
// Create invoices exactly on bucket boundaries
|
||||
$boundaries = [
|
||||
['days' => 0, 'balance' => 100, 'expected_bucket' => 'age_30'], // Today = 0-30
|
||||
['days' => 30, 'balance' => 200, 'expected_bucket' => 'age_30'], // Exactly 30 days
|
||||
['days' => 31, 'balance' => 300, 'expected_bucket' => 'age_60'], // Exactly 31 days
|
||||
['days' => 60, 'balance' => 400, 'expected_bucket' => 'age_60'], // Exactly 60 days
|
||||
['days' => 61, 'balance' => 500, 'expected_bucket' => 'age_90'], // Exactly 61 days
|
||||
['days' => 90, 'balance' => 600, 'expected_bucket' => 'age_90'], // Exactly 90 days
|
||||
['days' => 91, 'balance' => 700, 'expected_bucket' => 'age_120'], // Exactly 91 days
|
||||
['days' => 120, 'balance' => 800, 'expected_bucket' => 'age_120'], // Exactly 120 days
|
||||
['days' => 121, 'balance' => 900, 'expected_bucket' => 'age_120_plus'], // Exactly 121 days
|
||||
];
|
||||
|
||||
foreach ($boundaries as $boundary) {
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => $boundary['balance'],
|
||||
'due_date' => $now->copy()->subDays($boundary['days']),
|
||||
'is_deleted' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$currentResults = $this->getCurrentImplementationResults($client);
|
||||
$optimizedResults = $this->getOptimizedImplementationResults([$client->id]);
|
||||
$optimizedResult = $optimizedResults->get($client->id);
|
||||
|
||||
// Both implementations should produce same bucket totals
|
||||
foreach (['age_30', 'age_60', 'age_90', 'age_120', 'age_120_plus'] as $bucket) {
|
||||
$this->assertEquals(
|
||||
$currentResults[$bucket],
|
||||
$optimizedResult->$bucket,
|
||||
"Bucket boundary mismatch for {$bucket}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use App\Models\Account;
|
||||
use App\Models\Client;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use App\Services\Report\ARSummaryReport;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* Test ARSummaryReport service class with optimized implementation.
|
||||
*/
|
||||
class ARSummaryReportServiceTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
protected Company $company;
|
||||
protected User $user;
|
||||
protected Account $account;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->account = Account::factory()->create();
|
||||
|
||||
$this->company = Company::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create([
|
||||
'account_id' => $this->account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that optimized report generates without errors.
|
||||
*/
|
||||
public function testOptimizedReportGenerates()
|
||||
{
|
||||
// Create test data
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100,
|
||||
'due_date' => now()->subDays(15),
|
||||
]);
|
||||
|
||||
$report = new ARSummaryReport($this->company, [
|
||||
'report_keys' => [],
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$csv = $report->run();
|
||||
|
||||
$this->assertNotEmpty($csv);
|
||||
$this->assertStringContainsString('aged_receivable_summary_report', $csv);
|
||||
$this->assertStringContainsString($client->present()->name(), $csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that rollback flag works correctly.
|
||||
*/
|
||||
public function testRollbackToLegacyWorks()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 200,
|
||||
'due_date' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
// Force use of legacy implementation via reflection
|
||||
$report = new ARSummaryReport($this->company, [
|
||||
'report_keys' => [],
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$reflection = new \ReflectionClass($report);
|
||||
$property = $reflection->getProperty('useOptimizedQuery');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($report, false);
|
||||
|
||||
$csv = $report->run();
|
||||
|
||||
$this->assertNotEmpty($csv);
|
||||
$this->assertStringContainsString($client->present()->name(), $csv);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that both implementations produce same output.
|
||||
*/
|
||||
public function testBothImplementationsProduceSameOutput()
|
||||
{
|
||||
// Create test data
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'name' => 'Test Client ABC',
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100,
|
||||
'due_date' => now()->subDays(15),
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 300,
|
||||
'due_date' => now()->subDays(75),
|
||||
]);
|
||||
|
||||
// Run with optimized
|
||||
$reportOptimized = new ARSummaryReport($this->company, [
|
||||
'report_keys' => [],
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$csvOptimized = $reportOptimized->run();
|
||||
|
||||
// Run with legacy
|
||||
$reportLegacy = new ARSummaryReport($this->company, [
|
||||
'report_keys' => [],
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$reflection = new \ReflectionClass($reportLegacy);
|
||||
$property = $reflection->getProperty('useOptimizedQuery');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($reportLegacy, false);
|
||||
|
||||
$csvLegacy = $reportLegacy->run();
|
||||
|
||||
// Both should contain same client name and amounts
|
||||
$this->assertEquals(
|
||||
substr_count($csvOptimized, 'Test Client ABC'),
|
||||
substr_count($csvLegacy, 'Test Client ABC'),
|
||||
'Both implementations should include client name'
|
||||
);
|
||||
|
||||
// Both CSVs should have same structure (same number of lines)
|
||||
$this->assertEquals(
|
||||
substr_count($csvOptimized, "\n"),
|
||||
substr_count($csvLegacy, "\n"),
|
||||
'Both implementations should produce same CSV structure'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with empty client list.
|
||||
*/
|
||||
public function testWithNoClients()
|
||||
{
|
||||
$report = new ARSummaryReport($this->company, [
|
||||
'report_keys' => [],
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$csv = $report->run();
|
||||
|
||||
$this->assertNotEmpty($csv);
|
||||
$this->assertStringContainsString('aged_receivable_summary_report', $csv);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Tests\MockAccountData;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use App\Services\Report\ClientBalanceReport;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Client;
|
||||
|
||||
/**
|
||||
* Test suite for Client Balance Report optimization
|
||||
*
|
||||
* Validates that optimized single-query approach produces identical results
|
||||
* to legacy per-client query approach while reducing database queries.
|
||||
*/
|
||||
class ClientBalanceReportOptimizationTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
use MockAccountData;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->makeTestData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that optimized approach produces identical results to legacy
|
||||
*/
|
||||
public function testOptimizedMatchesLegacyResults()
|
||||
{
|
||||
// Create test data: 10 clients with varying invoice counts
|
||||
$clients = Client::factory()->count(10)->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
foreach ($clients as $index => $client) {
|
||||
// Create 0-5 invoices per client
|
||||
$invoiceCount = $index % 6;
|
||||
for ($i = 0; $i < $invoiceCount; $i++) {
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100 + ($i * 50),
|
||||
'amount' => 100 + ($i * 50),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Run both implementations
|
||||
$input = ['date_range' => 'all', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
|
||||
$legacyReport = new ClientBalanceReport($this->company, $input);
|
||||
$optimizedReport = new ClientBalanceReport($this->company, $input);
|
||||
|
||||
// Count queries for legacy
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
$legacyOutput = $legacyReport->run();
|
||||
$legacyQueries = count(DB::getQueryLog());
|
||||
|
||||
// Count queries for optimized (we'll implement this in the service)
|
||||
DB::flushQueryLog();
|
||||
// This will use optimized path when we implement it
|
||||
$optimizedOutput = $optimizedReport->run();
|
||||
$optimizedQueries = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
// For now, both use same implementation, so they should match
|
||||
$this->assertEquals($legacyOutput, $optimizedOutput);
|
||||
|
||||
// After optimization, we expect significant reduction
|
||||
// Legacy: ~2N queries (N clients × 2 queries/client)
|
||||
// Optimized: ~2 queries (1 for clients, 1 for aggregates)
|
||||
$this->assertGreaterThan(10, $legacyQueries, 'Legacy should make many queries');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query count reduction with optimized approach
|
||||
*/
|
||||
public function testQueryCountReduction()
|
||||
{
|
||||
$clientCount = 50;
|
||||
|
||||
// Create clients with invoices
|
||||
$clients = Client::factory()->count($clientCount)->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
foreach ($clients as $client) {
|
||||
Invoice::factory()->count(3)->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 500,
|
||||
]);
|
||||
}
|
||||
|
||||
$input = ['date_range' => 'all', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
$report = new ClientBalanceReport($this->company, $input);
|
||||
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
$report->run();
|
||||
$queryCount = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
// Optimized: ~10-15 queries (client fetch + aggregate + framework overhead)
|
||||
// Legacy: 100 queries (50 clients × 2)
|
||||
$this->assertLessThan($clientCount * 0.5, $queryCount,
|
||||
"Expected < " . ($clientCount * 0.5) . " queries (optimized), got {$queryCount}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with clients having no invoices
|
||||
*/
|
||||
public function testClientsWithNoInvoices()
|
||||
{
|
||||
// Create clients without invoices
|
||||
Client::factory()->count(5)->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
$input = ['date_range' => 'all', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
$report = new ClientBalanceReport($this->company, $input);
|
||||
$output = $report->run();
|
||||
|
||||
// Should return 0 for invoice count and balance
|
||||
$this->assertNotEmpty($output);
|
||||
$lines = array_filter(explode("\n", $output), fn($line) => !empty($line));
|
||||
$this->assertGreaterThanOrEqual(5, count($lines));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with date range filtering
|
||||
*/
|
||||
public function testDateRangeFiltering()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
// Create invoices at different dates
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100,
|
||||
'created_at' => now()->subDays(5),
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 200,
|
||||
'created_at' => now()->subDays(45),
|
||||
]);
|
||||
|
||||
// Test last 7 days filter
|
||||
$input = ['date_range' => 'last7', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
$report = new ClientBalanceReport($this->company, $input);
|
||||
$output = $report->run();
|
||||
|
||||
$this->assertNotEmpty($output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with different invoice statuses
|
||||
*/
|
||||
public function testInvoiceStatusFiltering()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
// Create invoices with different statuses
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 100,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_DRAFT,
|
||||
'balance' => 200,
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_PARTIAL,
|
||||
'balance' => 150,
|
||||
]);
|
||||
|
||||
$input = ['date_range' => 'all', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
$report = new ClientBalanceReport($this->company, $input);
|
||||
$output = $report->run();
|
||||
|
||||
// Should only include SENT and PARTIAL invoices
|
||||
$this->assertNotEmpty($output);
|
||||
$lines = array_filter(explode("\n", $output), fn($line) => !empty($line));
|
||||
$this->assertGreaterThanOrEqual(5, count($lines));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with large dataset to measure performance improvement
|
||||
*/
|
||||
public function testLargeDatasetPerformance()
|
||||
{
|
||||
$clientCount = 100;
|
||||
|
||||
// Create 100 clients with 5 invoices each
|
||||
$clients = Client::factory()->count($clientCount)->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
foreach ($clients as $client) {
|
||||
Invoice::factory()->count(5)->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 1000,
|
||||
]);
|
||||
}
|
||||
|
||||
$input = ['date_range' => 'all', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
$report = new ClientBalanceReport($this->company, $input);
|
||||
|
||||
DB::enableQueryLog();
|
||||
DB::flushQueryLog();
|
||||
$startTime = microtime(true);
|
||||
|
||||
$output = $report->run();
|
||||
|
||||
$endTime = microtime(true);
|
||||
$queryCount = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
$executionTime = $endTime - $startTime;
|
||||
|
||||
// Optimized: ~10-20 queries (aggregate query + framework overhead)
|
||||
// Legacy: 200 queries (100 clients × 2)
|
||||
$this->assertLessThan(50, $queryCount, "Expected < 50 queries (optimized), got {$queryCount}");
|
||||
$this->assertNotEmpty($output);
|
||||
|
||||
// Log performance metrics for comparison
|
||||
echo "\nPerformance Metrics (Optimized):\n";
|
||||
echo " Clients: {$clientCount}\n";
|
||||
echo " Queries: {$queryCount}\n";
|
||||
echo " Time: " . number_format($executionTime, 3) . "s\n";
|
||||
echo " Legacy queries: ~" . ($clientCount * 2) . "\n";
|
||||
echo " Improvement: " . round(($clientCount * 2) / max($queryCount, 1), 1) . "x\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Test with zero balance invoices
|
||||
*/
|
||||
public function testZeroBalanceInvoices()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
// Create invoices with zero balance (paid)
|
||||
Invoice::factory()->count(3)->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 0,
|
||||
'amount' => 100,
|
||||
]);
|
||||
|
||||
$input = ['date_range' => 'all', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
$report = new ClientBalanceReport($this->company, $input);
|
||||
$output = $report->run();
|
||||
|
||||
// Should still count the invoices
|
||||
$this->assertNotEmpty($output);
|
||||
$lines = array_filter(explode("\n", $output), fn($line) => !empty($line));
|
||||
$this->assertGreaterThanOrEqual(5, count($lines));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test report output structure
|
||||
*/
|
||||
public function testReportOutputStructure()
|
||||
{
|
||||
$client = Client::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'name' => 'Test Client',
|
||||
'number' => 'CLI-001',
|
||||
'id_number' => 'TAX-123',
|
||||
]);
|
||||
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $this->company->id,
|
||||
'user_id' => $this->user->id,
|
||||
'client_id' => $client->id,
|
||||
'status_id' => Invoice::STATUS_SENT,
|
||||
'balance' => 500,
|
||||
]);
|
||||
|
||||
$input = ['date_range' => 'all', 'report_keys' => [], 'user_id' => $this->user->id];
|
||||
$report = new ClientBalanceReport($this->company, $input);
|
||||
$output = $report->run();
|
||||
|
||||
// Verify output contains client data
|
||||
$this->assertNotEmpty($output);
|
||||
$lines = array_filter(explode("\n", $output), fn($line) => !empty($line));
|
||||
$this->assertGreaterThanOrEqual(5, count($lines));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue