diff --git a/app/Jobs/Company/CompanyImport.php b/app/Jobs/Company/CompanyImport.php index 65cfb70686..105066e448 100644 --- a/app/Jobs/Company/CompanyImport.php +++ b/app/Jobs/Company/CompanyImport.php @@ -1437,6 +1437,11 @@ class CompanyImport implements ShouldQueue continue; } + if (!$this->isValidFilePath($document->url)) { + nlog("Skipping document with invalid path: {$document->url}"); + continue; + } + if (!$this->isAllowedDocumentExtension($document->url)) { nlog("Skipping document with disallowed extension: {$document->url}"); continue; @@ -1564,6 +1569,19 @@ class CompanyImport implements ShouldQueue return in_array($extension, $allowed, true); } + private function isValidFilePath(string $filename): bool + { + if (str_contains($filename, "\0")) { + return false; + } + + if (str_contains($filename, '..')) { + return false; + } + + return true; + } + private function import_webhooks() { $this->genericImport( @@ -1941,7 +1959,9 @@ class CompanyImport implements ShouldQueue if ($new_obj instanceof CompanyLedger || $new_obj instanceof EInvoicingToken) { } elseif ($new_obj instanceof Backup) { - if (is_file("{$this->root_file_path}backups/{$obj->filename}")) { + if (!$this->isValidFilePath($obj->filename)) { + nlog("Skipping backup with invalid path: {$obj->filename}"); + } elseif (is_file("{$this->root_file_path}backups/{$obj->filename}")) { $file = file_get_contents("{$this->root_file_path}backups/{$obj->filename}"); $new_obj->filename = str_replace($this->old_company_key, $this->company->company_key, $obj->filename); $new_obj->save(); diff --git a/tests/Feature/Cron/InvoiceTaxSummaryCashTest.php b/tests/Feature/Cron/InvoiceTaxSummaryCashTest.php new file mode 100644 index 0000000000..2c92edb1ba --- /dev/null +++ b/tests/Feature/Cron/InvoiceTaxSummaryCashTest.php @@ -0,0 +1,404 @@ +faker = \Faker\Factory::create(); + + $this->withoutMiddleware(ThrottleRequests::class); + $this->withoutExceptionHandling(); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); // reset + parent::tearDown(); + } + + private function buildData(string $timezoneId = '15'): void + { + $this->account = Account::factory()->create([ + 'hosted_client_count' => 1000, + 'hosted_company_count' => 1000, + ]); + + $this->account->num_users = 3; + $this->account->save(); + + $this->user = User::factory()->create([ + 'account_id' => $this->account->id, + 'confirmation_code' => 'xyz123', + 'email' => \Illuminate\Support\Str::random(32).'@example.com', + ]); + + $settings = CompanySettings::defaults(); + $settings->client_online_payment_notification = false; + $settings->client_manual_payment_notification = false; + $settings->timezone_id = $timezoneId; + + $this->company = Company::factory()->create([ + 'account_id' => $this->account->id, + 'settings' => $settings, + ]); + + $this->company->settings = $settings; + $this->company->save(); + + $this->user->companies()->attach($this->company->id, [ + 'account_id' => $this->account->id, + 'is_owner' => 1, + 'is_admin' => 1, + 'is_locked' => 0, + 'notifications' => CompanySettings::notificationDefaults(), + 'settings' => null, + ]); + + $company_token = new \App\Models\CompanyToken(); + $company_token->user_id = $this->user->id; + $company_token->company_id = $this->company->id; + $company_token->account_id = $this->account->id; + $company_token->name = 'test token'; + $company_token->token = \Illuminate\Support\Str::random(64); + $company_token->is_system = true; + $company_token->save(); + + $truth = app()->make(\App\Utils\TruthSource::class); + $truth->setCompanyUser($this->user->company_users()->first()); + $truth->setCompanyToken($company_token); + $truth->setUser($this->user); + $truth->setCompany($this->company); + + $this->client = Client::factory()->create([ + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'is_deleted' => 0, + ]); + } + + /** + * Simulates the scenario from the bug: + * - Company in Australia/Sydney (UTC+11) timezone + * - Job runs on March 31 UTC (which is already April 1 in Sydney) + * - Invoice was paid in March + * - Cash entry should be created for period 2026-03-31 + * + * Before the fix, processCompanyTaxSummary used now()->subMonth() + * which on March 31 UTC = February, so it looked for Feb payments + * and found nothing — resulting in zero cash entries for March. + */ + public function testCashEntriesCreatedForCorrectMonthPositiveUtcOffset() + { + // timezone_id 105 = Australia/Sydney (UTC+10/+11) + $this->buildData('105'); + + // Create a paid invoice with a payment dated in March 2026 + $invoice = Invoice::factory()->create([ + 'client_id' => $this->client->id, + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'amount' => 0, + 'balance' => 0, + 'status_id' => Invoice::STATUS_SENT, + 'total_taxes' => 1, + 'date' => '2026-03-15', + 'terms' => '', + 'discount' => 0, + 'tax_rate1' => 10, + 'tax_name1' => 'GST', + 'uses_inclusive_taxes' => false, + 'line_items' => $this->buildLineItems(), + ]); + + $invoice = $invoice->calc()->getInvoice(); + $invoice->service()->markSent()->save(); + $invoice->service()->markPaid()->save(); + + $invoice->refresh(); + + // Verify payment exists and backdate the paymentable pivot to March + $payment = $invoice->payments()->first(); + $this->assertNotNull($payment, 'Payment should exist after markPaid'); + + // Set paymentable created_at to mid-March + \DB::table('paymentables') + ->where('payment_id', $payment->id) + ->where('paymentable_id', $invoice->id) + ->where('paymentable_type', 'invoices') + ->update(['created_at' => '2026-03-15 12:00:00']); + + // Simulate: job runs on March 31 at UTC 13:00 + // Australia/Sydney is UTC+11 in March (AEDT), so local time = April 1 00:00 + // This is when Sydney crosses midnight into the new month + Carbon::setTestNow(Carbon::parse('2026-03-31 13:00:00', 'UTC')); + + // Clear any existing transaction events for this invoice + TransactionEvent::where('invoice_id', $invoice->id)->delete(); + + // Run the job's processing for this company + $job = new InvoiceTaxSummary(); + $this->invokeProcessCompanyTaxSummary($job, $this->company); + + // Assert: cash entry should exist with period = 2026-03-31 (March) + $cashEntry = TransactionEvent::where('invoice_id', $invoice->id) + ->where('event_id', TransactionEvent::PAYMENT_CASH) + ->first(); + + $this->assertNotNull($cashEntry, 'Cash transaction event should be created for March'); + $this->assertEquals('2026-03-31', $cashEntry->period->format('Y-m-d'), 'Cash entry period should be end of March'); + + $this->account->delete(); + } + + /** + * Test that negative UTC offset timezones (processed on April 1) + * also get correct March cash entries. + */ + public function testCashEntriesCreatedForNegativeUtcOffset() + { + // timezone_id 15 = America/New_York (UTC-5/-4) + $this->buildData('15'); + + $invoice = Invoice::factory()->create([ + 'client_id' => $this->client->id, + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'amount' => 0, + 'balance' => 0, + 'status_id' => Invoice::STATUS_SENT, + 'total_taxes' => 1, + 'date' => '2026-03-20', + 'terms' => '', + 'discount' => 0, + 'tax_rate1' => 10, + 'tax_name1' => 'GST', + 'uses_inclusive_taxes' => false, + 'line_items' => $this->buildLineItems(), + ]); + + $invoice = $invoice->calc()->getInvoice(); + $invoice->service()->markSent()->save(); + $invoice->service()->markPaid()->save(); + + $invoice->refresh(); + + $payment = $invoice->payments()->first(); + $this->assertNotNull($payment); + + \DB::table('paymentables') + ->where('payment_id', $payment->id) + ->where('paymentable_id', $invoice->id) + ->where('paymentable_type', 'invoices') + ->update(['created_at' => '2026-03-20 18:00:00']); + + // Simulate: job runs on April 1 at UTC 04:00 + // New York is UTC-4 in April (EDT), so local time = April 1 00:00 + // This is when New York crosses midnight into the new month + Carbon::setTestNow(Carbon::parse('2026-04-01 04:00:00', 'UTC')); + + TransactionEvent::where('invoice_id', $invoice->id)->delete(); + + $job = new InvoiceTaxSummary(); + $this->invokeProcessCompanyTaxSummary($job, $this->company); + + $cashEntry = TransactionEvent::where('invoice_id', $invoice->id) + ->where('event_id', TransactionEvent::PAYMENT_CASH) + ->first(); + + $this->assertNotNull($cashEntry, 'Cash transaction event should be created for March'); + $this->assertEquals('2026-03-31', $cashEntry->period->format('Y-m-d'), 'Cash entry period should be end of March'); + + $this->account->delete(); + } + + /** + * Test that processCompanyTaxSummary skips mid-month midnight crossings. + * Only month-end transitions should trigger processing. + */ + public function testMidMonthMidnightCrossingSkipsProcessing() + { + // timezone_id 105 = Australia/Sydney (UTC+10/+11) + $this->buildData('105'); + + $invoice = Invoice::factory()->create([ + 'client_id' => $this->client->id, + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'amount' => 0, + 'balance' => 0, + 'status_id' => Invoice::STATUS_SENT, + 'total_taxes' => 1, + 'date' => '2026-03-10', + 'terms' => '', + 'discount' => 0, + 'tax_rate1' => 10, + 'tax_name1' => 'GST', + 'uses_inclusive_taxes' => false, + 'line_items' => $this->buildLineItems(), + ]); + + $invoice = $invoice->calc()->getInvoice(); + $invoice->service()->markSent()->save(); + $invoice->service()->markPaid()->save(); + + $invoice->refresh(); + + $payment = $invoice->payments()->first(); + $this->assertNotNull($payment); + + \DB::table('paymentables') + ->where('payment_id', $payment->id) + ->where('paymentable_id', $invoice->id) + ->where('paymentable_type', 'invoices') + ->update(['created_at' => '2026-03-10 12:00:00']); + + TransactionEvent::where('invoice_id', $invoice->id)->delete(); + + // Simulate: mid-month midnight crossing on March 15 in Sydney + // Sydney is UTC+11 (AEDT), so March 14 UTC 13:00 = March 15 00:00 AEDT + Carbon::setTestNow(Carbon::parse('2026-03-14 13:00:00', 'UTC')); + + $job = new InvoiceTaxSummary(); + $this->invokeProcessCompanyTaxSummary($job, $this->company); + + // No events should be created — mid-month crossing is skipped + $eventCount = TransactionEvent::where('invoice_id', $invoice->id)->count(); + $this->assertEquals(0, $eventCount, 'No transaction events should be created for mid-month midnight crossing'); + + $this->account->delete(); + } + + /** + * Test that running the job twice for the same month-end does not + * create duplicate PAYMENT_CASH entries (period-based dedup). + */ + public function testNoDuplicateCashEntriesOnRerun() + { + // timezone_id 105 = Australia/Sydney + $this->buildData('105'); + + $invoice = Invoice::factory()->create([ + 'client_id' => $this->client->id, + 'user_id' => $this->user->id, + 'company_id' => $this->company->id, + 'amount' => 0, + 'balance' => 0, + 'status_id' => Invoice::STATUS_SENT, + 'total_taxes' => 1, + 'date' => '2026-03-15', + 'terms' => '', + 'discount' => 0, + 'tax_rate1' => 10, + 'tax_name1' => 'GST', + 'uses_inclusive_taxes' => false, + 'line_items' => $this->buildLineItems(), + ]); + + $invoice = $invoice->calc()->getInvoice(); + $invoice->service()->markSent()->save(); + $invoice->service()->markPaid()->save(); + + $invoice->refresh(); + + $payment = $invoice->payments()->first(); + $this->assertNotNull($payment); + + \DB::table('paymentables') + ->where('payment_id', $payment->id) + ->where('paymentable_id', $invoice->id) + ->where('paymentable_type', 'invoices') + ->update(['created_at' => '2026-03-15 12:00:00']); + + TransactionEvent::where('invoice_id', $invoice->id)->delete(); + + // Sydney month-end: March 31 UTC 13:00 = April 1 00:00 AEDT + Carbon::setTestNow(Carbon::parse('2026-03-31 13:00:00', 'UTC')); + + $job = new InvoiceTaxSummary(); + + // Run once — should create the cash entry + $this->invokeProcessCompanyTaxSummary($job, $this->company); + + $cashCount = TransactionEvent::where('invoice_id', $invoice->id) + ->where('event_id', TransactionEvent::PAYMENT_CASH) + ->count(); + $this->assertEquals(1, $cashCount, 'First run should create one cash entry'); + + // Run again — should NOT create a duplicate + $this->invokeProcessCompanyTaxSummary($job, $this->company); + + $cashCount = TransactionEvent::where('invoice_id', $invoice->id) + ->where('event_id', TransactionEvent::PAYMENT_CASH) + ->count(); + $this->assertEquals(1, $cashCount, 'Second run should not create a duplicate cash entry'); + + $this->account->delete(); + } + + /** + * Use reflection to call the private processCompanyTaxSummary method. + */ + private function invokeProcessCompanyTaxSummary(InvoiceTaxSummary $job, Company $company): void + { + $method = new \ReflectionMethod(InvoiceTaxSummary::class, 'processCompanyTaxSummary'); + $method->setAccessible(true); + $method->invoke($job, $company); + } + + private function buildLineItems(): array + { + $line_items = []; + + $item = InvoiceItemFactory::create(); + $item->quantity = 1; + $item->cost = 100; + $item->product_key = 'test'; + $item->notes = 'test_product'; + + $line_items[] = $item; + + return $line_items; + } +}