diff --git a/VERSION.txt b/VERSION.txt index 941aec3f27..3ab1c79d29 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -5.12.35 \ No newline at end of file +5.12.36 \ No newline at end of file diff --git a/app/Console/Commands/Elastic/RebuildElasticIndexes.php b/app/Console/Commands/Elastic/RebuildElasticIndexes.php index 83c1553568..07b0942026 100644 --- a/app/Console/Commands/Elastic/RebuildElasticIndexes.php +++ b/app/Console/Commands/Elastic/RebuildElasticIndexes.php @@ -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 diff --git a/app/DataMapper/Cancellation.php b/app/DataMapper/Cancellation.php index 5d4fab7f8e..bb9b250f20 100644 --- a/app/DataMapper/Cancellation.php +++ b/app/DataMapper/Cancellation.php @@ -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 diff --git a/app/Http/Controllers/ClientPortal/CreditController.php b/app/Http/Controllers/ClientPortal/CreditController.php index 94b0a267d2..e597c1b07b 100644 --- a/app/Http/Controllers/ClientPortal/CreditController.php +++ b/app/Http/Controllers/ClientPortal/CreditController.php @@ -36,7 +36,7 @@ class CreditController extends Controller $data = [ 'credit' => $credit, - 'key' => $invitation ? $invitation->key : false, + '_key' => $invitation ? $invitation->key : false, 'invitation' => $invitation ]; diff --git a/app/Http/Controllers/ClientPortal/InvoiceController.php b/app/Http/Controllers/ClientPortal/InvoiceController.php index 7330a6774f..79ae26f042 100644 --- a/app/Http/Controllers/ClientPortal/InvoiceController.php +++ b/app/Http/Controllers/ClientPortal/InvoiceController.php @@ -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], diff --git a/app/Http/Controllers/ClientPortal/QuoteController.php b/app/Http/Controllers/ClientPortal/QuoteController.php index 4d1a41787d..a0b1577604 100644 --- a/app/Http/Controllers/ClientPortal/QuoteController.php +++ b/app/Http/Controllers/ClientPortal/QuoteController.php @@ -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, ]; diff --git a/app/Http/Controllers/Gateways/BlockonomicsController.php b/app/Http/Controllers/Gateways/BlockonomicsController.php deleted file mode 100644 index b7d1cbb80f..0000000000 --- a/app/Http/Controllers/Gateways/BlockonomicsController.php +++ /dev/null @@ -1,58 +0,0 @@ -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; - - } -} diff --git a/app/Http/Controllers/VendorPortal/PurchaseOrderController.php b/app/Http/Controllers/VendorPortal/PurchaseOrderController.php index 5edf701cff..322a98f9c3 100644 --- a/app/Http/Controllers/VendorPortal/PurchaseOrderController.php +++ b/app/Http/Controllers/VendorPortal/PurchaseOrderController.php @@ -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, diff --git a/app/Http/Requests/PurchaseOrder/StorePurchaseOrderRequest.php b/app/Http/Requests/PurchaseOrder/StorePurchaseOrderRequest.php index 7dc3aaf271..ca23535a62 100644 --- a/app/Http/Requests/PurchaseOrder/StorePurchaseOrderRequest.php +++ b/app/Http/Requests/PurchaseOrder/StorePurchaseOrderRequest.php @@ -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')]); } diff --git a/app/Http/Requests/Task/StoreTaskRequest.php b/app/Http/Requests/Task/StoreTaskRequest.php index d9e5cd281c..4ced70470b 100644 --- a/app/Http/Requests/Task/StoreTaskRequest.php +++ b/app/Http/Requests/Task/StoreTaskRequest.php @@ -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 { diff --git a/app/Http/Requests/Task/UpdateTaskRequest.php b/app/Http/Requests/Task/UpdateTaskRequest.php index 0610fd1813..f880013a5f 100644 --- a/app/Http/Requests/Task/UpdateTaskRequest.php +++ b/app/Http/Requests/Task/UpdateTaskRequest.php @@ -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])); } } diff --git a/app/Http/ValidationRules/Account/BlackListRule.php b/app/Http/ValidationRules/Account/BlackListRule.php index a7ef7c0d50..849a4d0384 100644 --- a/app/Http/ValidationRules/Account/BlackListRule.php +++ b/app/Http/ValidationRules/Account/BlackListRule.php @@ -22,6 +22,7 @@ class BlackListRule implements ValidationRule { /** Bad domains +/- disposable email domains */ private array $blacklist = [ + "bablace.com", "moonfee.com", "edus2.us", "educj.org", diff --git a/app/Listeners/Activity/CreatedCreditActivity.php b/app/Listeners/Activity/CreatedCreditActivity.php index 27ea02ccc0..8149f75cad 100644 --- a/app/Listeners/Activity/CreatedCreditActivity.php +++ b/app/Listeners/Activity/CreatedCreditActivity.php @@ -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; diff --git a/app/Listeners/Activity/DeleteCreditActivity.php b/app/Listeners/Activity/DeleteCreditActivity.php index 5f66cbca96..94fd05631a 100644 --- a/app/Listeners/Activity/DeleteCreditActivity.php +++ b/app/Listeners/Activity/DeleteCreditActivity.php @@ -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; diff --git a/app/Listeners/Activity/UpdatedCreditActivity.php b/app/Listeners/Activity/UpdatedCreditActivity.php index 61bf13254b..35523d4364 100644 --- a/app/Listeners/Activity/UpdatedCreditActivity.php +++ b/app/Listeners/Activity/UpdatedCreditActivity.php @@ -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; diff --git a/app/Livewire/BillingPortal/Payments/BlockonomicsPriceDisplay.php b/app/Livewire/BillingPortal/Payments/BlockonomicsPriceDisplay.php new file mode 100644 index 0000000000..bc1859047b --- /dev/null +++ b/app/Livewire/BillingPortal/Payments/BlockonomicsPriceDisplay.php @@ -0,0 +1,82 @@ + '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'); + } +} diff --git a/app/Livewire/BillingPortal/Payments/BlockonomicsQrCode.php b/app/Livewire/BillingPortal/Payments/BlockonomicsQrCode.php new file mode 100644 index 0000000000..d707407b21 --- /dev/null +++ b/app/Livewire/BillingPortal/Payments/BlockonomicsQrCode.php @@ -0,0 +1,83 @@ +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'); + } +} diff --git a/app/PaymentDrivers/Blockonomics/Blockonomics.php b/app/PaymentDrivers/Blockonomics/Blockonomics.php index e0b671aa1a..92dbb2ebca 100644 --- a/app/PaymentDrivers/Blockonomics/Blockonomics.php +++ b/app/PaymentDrivers/Blockonomics/Blockonomics.php @@ -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) diff --git a/app/Repositories/ClientGatewayTokenRepository.php b/app/Repositories/ClientGatewayTokenRepository.php index 65db94e571..46f8f0a60e 100644 --- a/app/Repositories/ClientGatewayTokenRepository.php +++ b/app/Repositories/ClientGatewayTokenRepository.php @@ -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; diff --git a/app/Services/Report/ARSummaryReport.php b/app/Services/Report/ARSummaryReport.php index 72c537fff9..b65fc98815 100644 --- a/app/Services/Report/ARSummaryReport.php +++ b/app/Services/Report/ARSummaryReport.php @@ -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; diff --git a/app/Services/Report/ClientBalanceReport.php b/app/Services/Report/ClientBalanceReport.php index 651b2d3e37..5ada5fed89 100644 --- a/app/Services/Report/ClientBalanceReport.php +++ b/app/Services/Report/ClientBalanceReport.php @@ -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) diff --git a/config/ninja.php b/config/ninja.php index f434fb8fa0..6348c6b2f3 100644 --- a/config/ninja.php +++ b/config/ninja.php @@ -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), diff --git a/elastic/migrations/2025_08_31_221640_create_invoices_index.php b/elastic/migrations/2025_08_31_221640_create_invoices_index.php index aad798f900..e7e1b78bb8 100644 --- a/elastic/migrations/2025_08_31_221640_create_invoices_index.php +++ b/elastic/migrations/2025_08_31_221640_create_invoices_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221641_create_quotes_index.php b/elastic/migrations/2025_08_31_221641_create_quotes_index.php index 64b88795b9..476510d0ea 100644 --- a/elastic/migrations/2025_08_31_221641_create_quotes_index.php +++ b/elastic/migrations/2025_08_31_221641_create_quotes_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221642_create_credits_index.php b/elastic/migrations/2025_08_31_221642_create_credits_index.php index ab95e05d40..1fe57cdbf4 100644 --- a/elastic/migrations/2025_08_31_221642_create_credits_index.php +++ b/elastic/migrations/2025_08_31_221642_create_credits_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php b/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php index 53e5086ac4..4533c5a6dd 100644 --- a/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php +++ b/elastic/migrations/2025_08_31_221643_create_recurring_invoices_index.php @@ -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' => [ diff --git a/elastic/migrations/2025_08_31_221644_create_purchase_orders_index.php b/elastic/migrations/2025_08_31_221644_create_purchase_orders_index.php index 6b765a6e16..028bd345af 100644 --- a/elastic/migrations/2025_08_31_221644_create_purchase_orders_index.php +++ b/elastic/migrations/2025_08_31_221644_create_purchase_orders_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221645_create_vendors_index.php b/elastic/migrations/2025_08_31_221645_create_vendors_index.php index f38f87cfdf..76ea3b4b13 100644 --- a/elastic/migrations/2025_08_31_221645_create_vendors_index.php +++ b/elastic/migrations/2025_08_31_221645_create_vendors_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221646_create_expenses_index.php b/elastic/migrations/2025_08_31_221646_create_expenses_index.php index d847e95107..9a8fc4257e 100644 --- a/elastic/migrations/2025_08_31_221646_create_expenses_index.php +++ b/elastic/migrations/2025_08_31_221646_create_expenses_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221647_create_projects_index.php b/elastic/migrations/2025_08_31_221647_create_projects_index.php index 6cc21c0f84..f6d09e821c 100644 --- a/elastic/migrations/2025_08_31_221647_create_projects_index.php +++ b/elastic/migrations/2025_08_31_221647_create_projects_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221648_create_tasks_index.php b/elastic/migrations/2025_08_31_221648_create_tasks_index.php index 30aeedf806..7dcbd9fa9d 100644 --- a/elastic/migrations/2025_08_31_221648_create_tasks_index.php +++ b/elastic/migrations/2025_08_31_221648_create_tasks_index.php @@ -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 } } - diff --git a/elastic/migrations/2025_08_31_221649_create_client_contacts_index.php b/elastic/migrations/2025_08_31_221649_create_client_contacts_index.php index 4d19002bd4..2883a77b59 100644 --- a/elastic/migrations/2025_08_31_221649_create_client_contacts_index.php +++ b/elastic/migrations/2025_08_31_221649_create_client_contacts_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php b/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php index 9a8de4da39..f110eea727 100644 --- a/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php +++ b/elastic/migrations/2025_08_31_221650_create_vendor_contacts_index.php @@ -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 diff --git a/elastic/migrations/2025_08_31_221651_create_clients_index.php b/elastic/migrations/2025_08_31_221651_create_clients_index.php index c18b69bbed..0b989365b7 100644 --- a/elastic/migrations/2025_08_31_221651_create_clients_index.php +++ b/elastic/migrations/2025_08_31_221651_create_clients_index.php @@ -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 - diff --git a/phpstan.neon b/phpstan.neon index e68999190d..0b2d3fd189 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -22,6 +22,7 @@ parameters: - 'app/Utils/Traits/*' - 'Modules/Accounting/*' - 'tests/*' + - '~/.cursor/*' universalObjectCratesClasses: - App\DataMapper\Tax\RuleInterface - App\DataMapper\FeesAndLimits diff --git a/public/build/assets/blockonomics-bab011a6.js b/public/build/assets/blockonomics-bab011a6.js new file mode 100644 index 0000000000..10ce02d5d9 --- /dev/null +++ b/public/build/assets/blockonomics-bab011a6.js @@ -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(` + + `),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 ";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()); diff --git a/public/build/assets/blockonomics-c3966bec.js b/public/build/assets/blockonomics-c3966bec.js deleted file mode 100644 index 96863b34cf..0000000000 --- a/public/build/assets/blockonomics-c3966bec.js +++ /dev/null @@ -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(` - - `),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 ";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()); diff --git a/public/build/manifest.json b/public/build/manifest.json index f2fff80460..235fc9246e 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -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" ], diff --git a/resources/js/clients/payments/blockonomics.js b/resources/js/clients/payments/blockonomics.js index 038b746b1e..5a933095eb 100644 --- a/resources/js/clients/payments/blockonomics.js +++ b/resources/js/clients/payments/blockonomics.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 } diff --git a/resources/views/portal/ninja2020/components/livewire/blockonomics-price-display.blade.php b/resources/views/portal/ninja2020/components/livewire/blockonomics-price-display.blade.php new file mode 100644 index 0000000000..76506eda7e --- /dev/null +++ b/resources/views/portal/ninja2020/components/livewire/blockonomics-price-display.blade.php @@ -0,0 +1,98 @@ +