invoiceninja/tests/Feature/Export/TaxPeriodReportTest.php

2285 lines
77 KiB
PHP

<?php
/**
* 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
*/
namespace Tests\Feature\Export;
use Tests\TestCase;
use App\Models\User;
use App\Models\Client;
use App\Models\Account;
use App\Models\Company;
use App\Models\Invoice;
use App\Utils\Traits\MakesHash;
use App\Models\TransactionEvent;
use App\DataMapper\CompanySettings;
use App\Factory\InvoiceItemFactory;
use App\Services\Report\TaxPeriodReport;
use Illuminate\Routing\Middleware\ThrottleRequests;
use App\Listeners\Invoice\InvoiceTransactionEventEntry;
use App\Listeners\Payment\PaymentTransactionEventEntry;
use App\Listeners\Invoice\InvoiceTransactionEventEntryCash;
use App\Repositories\InvoiceRepository;
/**
*
*/
class TaxPeriodReportTest extends TestCase
{
use MakesHash;
public $faker;
private $_token;
protected function setUp(): void
{
parent::setUp();
$this->faker = \Faker\Factory::create();
$this->withoutMiddleware(
ThrottleRequests::class
);
}
public $company;
public $user;
public $payload;
public $account;
public $client;
/**
* start_date - Y-m-d
end_date - Y-m-d
date_range -
all
last7
last30
this_month
last_month
this_quarter
last_quarter
this_year
custom
is_income_billed - true = Invoiced || false = Payments
expense_billed - true = Expensed || false = Expenses marked as paid
include_tax - true tax_included || false - tax_excluded
*/
private function buildData()
{
$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;
$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' => \App\DataMapper\CompanySettings::notificationDefaults(),
'settings' => null,
]);
$this->_token =\Illuminate\Support\Str::random(64);
$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 = $this->_token;
$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->payload = [
'start_date' => '2000-01-01',
'end_date' => '2030-01-11',
'date_range' => 'custom',
'is_income_billed' => true,
'include_tax' => false,
'user_id' => $this->user->id,
];
$this->client = Client::factory()->create([
'user_id' => $this->user->id,
'company_id' => $this->company->id,
'is_deleted' => 0,
]);
}
public function testSingleInvoiceTaxReportStructure()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->createInvitations()->save();
$invoice->fresh();
(new InvoiceTransactionEventEntry())->run($invoice);
$invoice->fresh();
$transaction_event = $invoice->transaction_events()->first();
// nlog($transaction_event->metadata->toArray());
$this->assertNotNull($transaction_event);
$this->assertEquals(330, $transaction_event->invoice_amount);
$this->assertEquals('2025-10-01', $invoice->date);
$this->assertEquals('2025-10-31', $invoice->due_date);
$this->assertEquals(330, $invoice->balance);
$this->assertEquals(30, $invoice->total_taxes);
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$this->assertNotEmpty($data);
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false,
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$this->assertCount(1,$data['invoices']);
$this->assertCount(1,$data['invoice_items']);
$invoice->service()->markPaid()->save();
(new InvoiceTransactionEventEntryCash())->run($invoice, '2025-10-01', '2025-10-31');
$invoice->fresh();
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false,
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$this->assertCount(2, $invoice->transaction_events);
$this->assertCount(2, $data['invoices']);
$this->assertCount(2, $data['invoice_items']);
$this->travelBack();
}
/**
* Test that we adjust appropriately across reporting period where an invoice amount has been both
* increased and decreased, and assess that the adjustments are correct.
*
* @return void
*/
public function testInvoiceReportingOverMultiplePeriodsWithAccrualAccountingCheckAdjustmentsForIncreases()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->createInvitations()->save();
$invoice->fresh();
(new InvoiceTransactionEventEntry())->run($invoice);
$invoice->fresh();
$transaction_event = $invoice->transaction_events()->first();
$this->assertEquals('2025-10-31', $transaction_event->period->format('Y-m-d'));
$this->assertEquals(330, $transaction_event->invoice_amount);
$this->assertEquals(30, $transaction_event->metadata->tax_report->tax_summary->total_taxes);
$this->assertEquals(0, $transaction_event->invoice_paid_to_date);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 400;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice->line_items = $line_items;
$invoice = $invoice->calc()->getInvoice();
$invoice->fresh();
(new InvoiceTransactionEventEntry())->run($invoice);
$transaction_event = $invoice->transaction_events()->orderBy('timestamp', 'desc')->first();
// nlog($transaction_event->metadata);
$this->assertEquals('2025-11-30', $transaction_event->period->format('Y-m-d'));
$this->assertEquals(440, $transaction_event->invoice_amount);
$this->assertEquals("delta", $transaction_event->metadata->tax_report->tax_summary->status);
$this->assertEquals(40, $transaction_event->metadata->tax_report->tax_summary->total_taxes);
$this->assertEquals(100, $transaction_event->metadata->tax_report->tax_summary->adjustment);
$this->assertEquals(10, $transaction_event->metadata->tax_report->tax_summary->tax_adjustment);
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
// nlog($data);
$invoice_report = $data['invoices'][1];
$item_report = $data['invoice_items'][1];
$this->assertEquals(100, $invoice_report[2]); //adjusted amount ex tax
$this->assertEquals(10, $invoice_report[4]); //adjusted tax amount
$this->assertEquals(100, $item_report[5]); //Taxable Adjustment Amount
$this->assertEquals(10, $item_report[4]); //adjusted tax amount
}
public function testInvoiceReportingOverMultiplePeriodsWithAccrualAccountingCheckAdjustmentsForDecreases()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->createInvitations()->save();
$invoice->fresh();
(new InvoiceTransactionEventEntry())->run($invoice);
$invoice->fresh();
$transaction_event = $invoice->transaction_events()->first();
$this->assertEquals('2025-10-31', $transaction_event->period->format('Y-m-d'));
$this->assertEquals(330, $transaction_event->invoice_amount);
$this->assertEquals(30, $transaction_event->metadata->tax_report->tax_summary->total_taxes);
$this->assertEquals(0, $transaction_event->invoice_paid_to_date);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 200;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice->line_items = $line_items;
$invoice = $invoice->calc()->getInvoice();
$invoice->fresh();
(new InvoiceTransactionEventEntry())->run($invoice);
$transaction_event = $invoice->transaction_events()->orderBy('timestamp', 'desc')->first();
// nlog($transaction_event->metadata);
$this->assertEquals('2025-11-30', $transaction_event->period->format('Y-m-d'));
$this->assertEquals(220, $transaction_event->invoice_amount);
$this->assertEquals("delta", $transaction_event->metadata->tax_report->tax_summary->status);
$this->assertEquals(20, $transaction_event->metadata->tax_report->tax_summary->total_taxes);
$this->assertEquals(-100, $transaction_event->metadata->tax_report->tax_summary->adjustment);
$this->assertEquals(-10, $transaction_event->metadata->tax_report->tax_summary->tax_adjustment);
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$invoice_report = $data['invoices'][1];
$item_report = $data['invoice_items'][1];
$this->assertEquals(-100, $invoice_report[2]); //adjusted amount ex tax
$this->assertEquals(-10, $invoice_report[4]); //adjusted tax amount
$this->assertEquals(-100, $item_report[5]); //Taxable Adjustment Amount
$this->assertEquals(-10, $item_report[4]); //adjusted tax amount
}
public function testInvoiceReportingOverMultiplePeriodsWithCashAccountingCheckAdjustments()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->createInvitations()->markPaid()->save();
$invoice = $invoice->fresh();
// (new InvoiceTransactionEventEntry())->run($invoice);
// (new InvoiceTransactionEventEntryCash())->run($invoice, '2025-10-01', '2025-10-31');
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 2)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true, //accrual
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$transaction_event = $invoice->transaction_events()
->where('event_id', '!=', TransactionEvent::INVOICE_UPDATED)
->first();
$this->assertNotNull($transaction_event);
$this->assertEquals('2025-10-31', $transaction_event->period->format('Y-m-d'));
$this->assertEquals(330, $transaction_event->invoice_amount);
$this->assertEquals(30, $transaction_event->metadata->tax_report->tax_summary->total_taxes);
$this->assertEquals(330, $transaction_event->invoice_paid_to_date);
}
public function testInvoiceWithRefundAndCashReportsAreCorrect()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->createInvitations()->markPaid()->save();
$invoice = $invoice->fresh();
$payment = $invoice->payments()->first();
/**
* refund one third of the total invoice amount
*
* this should result in a tax adjustment of -10
* and a reportable taxable_amount adjustment of -100
*
*/
$refund_data = [
'id' => $payment->hashed_id,
'date' => '2025-10-15',
'invoices' => [
[
'invoice_id' => $invoice->hashed_id,
'amount' => 110,
],
]
];
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 15)->startOfDay());
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->_token,
])->postJson('/api/v1/payments/refund', $refund_data);
$response->assertStatus(200);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 02)->startOfDay());
//cash should have NONE
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, //cash
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$invoice = $invoice->fresh();
$payment = $invoice->payments()->first();
$te = $invoice->transaction_events()->where('event_id', '!=', TransactionEvent::INVOICE_UPDATED)->get();
// nlog($te->toArray());
$this->assertEquals(110, $invoice->balance);
$this->assertEquals(220, $invoice->paid_to_date);
$this->assertEquals(3, $invoice->status_id);
$this->assertEquals(110, $payment->refunded);
$this->assertEquals(330, $payment->applied);
$this->assertEquals(330, $payment->amount);
$this->assertEquals(110, $te->first()->payment_refunded);
$this->assertEquals(330, $te->first()->payment_applied);
$this->assertEquals(330, $te->first()->payment_amount);
$this->assertEquals(220, $te->first()->invoice_paid_to_date);
$this->assertEquals(110, $te->first()->invoice_balance);
}
public function testInvoiceWithRefundAndCashReportsAreCorrectAcrossReportingPeriods()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->type_id = 1;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->createInvitations()->markPaid()->save();
$invoice = $invoice->fresh();
$payment = $invoice->payments()->first();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 02)->startOfDay());
//cash should have NONE
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, //cash
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
/**
* refund one third of the total invoice amount
*
* this should result in a tax adjustment of -10
* and a reportable taxable_amount adjustment of -100
*
*/
$refund_data = [
'id' => $payment->hashed_id,
'date' => '2025-11-02',
'invoices' => [
[
'invoice_id' => $invoice->hashed_id,
'amount' => 110,
],
]
];
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->_token,
])->postJson('/api/v1/payments/refund', $refund_data);
$response->assertStatus(200);
$invoice = $invoice->fresh();
$payment = $invoice->payments()->first();
(new PaymentTransactionEventEntry($payment, [$invoice->id], $payment->company->db, 110, false))->handle();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 02)->startOfDay());
$invoice = $invoice->fresh();
//cash should have NONE
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => false, //cash
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
// nlog($invoice->fresh()->transaction_events()->get()->toArray());
// nlog($data);
$this->assertCount(2, $data['invoices']);
$invoice_report = $data['invoices'][1];
$this->assertEquals(-110, $invoice_report[5]);
$this->assertEquals(-10, $invoice_report[4]);
$this->assertEquals('adjustment', $invoice_report[6]);
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => false, //cash
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
nlog($data);
}
// ========================================
// CANCELLED INVOICE TESTS
// ========================================
/**
* Test: Invoice cancelled in the same period it was created (accrual)
* Expected: No tax liability (invoice never became reportable)
*/
public function testCancelledInvoiceInSamePeriodAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'date' => now()->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
// Cancel in same period
$invoice->service()->handleCancellation()->save();
(new InvoiceTransactionEventEntry())->run($invoice);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true, // accrual
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
// Should have cancelled status, but no tax liability for unpaid portion
$this->assertCount(2, $data['invoices']); // Header + 1 invoice
$invoice_report = $data['invoices'][1];
$this->assertEquals('cancelled', $invoice_report[6]); // Status
$this->assertEquals(0, $invoice_report[3]); // No paid amount
$this->assertEquals(0, $invoice_report[4]); // No taxes reportable
$this->travelBack();
}
/**
* Test: Invoice cancelled in a later period (accrual)
* Expected: Original period shows full liability, cancellation period shows reversal
*/
public function testCancelledInvoiceInNextPeriodAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'date' => '2025-12-01',
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
$payload = [
'start_date' => '2025-12-01',
'end_date' => '2025-12-31',
'date_range' => 'custom',
'is_income_billed' => true,
];
// Move to next period and cancel
$this->travelTo(\Carbon\Carbon::createFromDate(2026, 1, 2)->startOfDay());
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$invoice->fresh();
$invoice->service()->handleCancellation()->save();
$invoice->save();
// (new InvoiceTransactionEventEntry())->run($invoice);
$this->travelTo(\Carbon\Carbon::createFromDate(2026, 2, 1)->startOfDay());
// Check December report - should show full $30 GST liability
$payload = [
'start_date' => '2026-01-01',
'end_date' => '2026-01-31',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
nlog($data);
// nlog($invoice->fresh()->transaction_events()->get()->toArray());
// Find our specific invoice in the report
$found = false;
foreach ($data['invoices'] as $idx => $row) {
if ($idx === 0) continue; // Skip header
if ((string)$row[0] == (string)$invoice->number) { // Match by invoice number
// Debug: show what we found
nlog("Found invoice {$invoice->number}: Amount={$row[2]}, Paid={$row[3]}, Tax={$row[4]}, Status={$row[6]}");
$found = true;
break;
}
}
$this->assertTrue($found, 'Invoice not found in Jan');
// Check January report - should show cancelled status
$payload['start_date'] = '2026-01-01';
$payload['end_date'] = '2026-01-31';
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
nlog($data);
// Find our specific invoice in January report
$found = false;
foreach ($data['invoices'] as $idx => $row) {
if ($idx === 0) continue; // Skip header
nlog("dafad" . $row[0] . " - " . $invoice->number);
if ((string)$row[0] == (string)$invoice->number) { // Match by invoice number
$this->assertEquals('cancelled', $row[6]);
$found = true;
break;
}
}
$this->assertTrue($found, 'Invoice not found in January report 2');
$this->travelBack();
}
/**
* Test: Invoice with partial payment then cancelled (accrual)
* Expected: Report taxes on paid portion only
*/
public function testCancelledInvoiceWithPartialPaymentAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'date' => now()->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
// Pay half (165 = 50% of 330)
$invoice->service()->applyPaymentAmount(165, 'partial-payment')->save();
$invoice = $invoice->fresh();
(new InvoiceTransactionEventEntry())->run($invoice);
// Move to next period and cancel
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$invoice->fresh();
$invoice->service()->handleCancellation()->save();
(new InvoiceTransactionEventEntry())->run($invoice);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
// November report should show cancelled status with 50% of taxes
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$invoice_report = $data['invoices'][1];
$this->assertEquals('cancelled', $invoice_report[6]);
// TODO: Verify if these values are correct for cancelled invoice with partial payment
// Current behavior may need review
$this->assertGreaterThan(0, $invoice_report[4]); // Tax amount should be positive
$this->travelBack();
}
// ========================================
// DELETED INVOICE TESTS
// ========================================
/**
* Test: Invoice deleted in same period (accrual)
* Expected: No transaction event created (invoice never became reportable)
*/
public function testDeletedInvoiceInSamePeriodAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'date' => now()->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
// Delete in same period (before transaction event created)
$invoice->delete();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
// Should only have header row, no invoice data
$this->assertCount(1, $data['invoices']); // Just header
$this->travelBack();
}
/**
* Test: Invoice deleted in next period (accrual)
* Expected: Original period shows liability, deletion period shows negative reversal
*/
public function testDeletedInvoiceInNextPeriodAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$item->tax_name2 = '';
$item->tax_rate2 = 0;
$item->tax_name3 = '';
$item->tax_rate3 = 0;
$item->discount = 0;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'public_notes' => 'iamdeleted',
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 2)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true,
];
nlog("initial invoice");
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
// Move to next period and delete
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$invoice->fresh();
$repo = new InvoiceRepository();
$repo->delete($invoice);
//there would be no trigger for this invoice in a deleted state to have a transaction event entry.
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 2)->startOfDay());
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => true,
];
// nlog("post delete");
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
// nlog($invoice->fresh()->transaction_events()->get()->toArray());
// (new InvoiceTransactionEventEntry())->run($invoice);
// nlog($data);
$this->assertCount(2, $data['invoices']);
$this->assertEquals(-30, $data['invoices'][1][4]); // +$30 GST
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$this->assertEquals(-30, $data['invoices'][1][4]); // +$30 GST
// November shows -$30 GST (reversal)
$payload['start_date'] = '2025-11-01';
$payload['end_date'] = '2025-11-30';
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
nlog($data);
$this->assertCount(2, $data['invoices']);
$invoice_report = $data['invoices'][1];
$this->assertEquals('deleted', $invoice_report[6]);
$this->assertEquals(-330, $invoice_report[2]); // Negative invoice amount
$this->assertEquals(-30, $invoice_report[4]); // Negative GST
$this->travelBack();
}
/**
* Test: Paid invoice deleted in next period (accrual)
* Expected: Reversal includes the paid amount
*/
public function testDeletedPaidInvoiceInNextPeriodAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'public_notes' => 'iamdeleted',
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->markPaid()->save();
// Move to next period and delete
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$repo = new InvoiceRepository();
$repo->delete($invoice);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
// November shows deleted with negative amounts
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
nlog($data);
$this->assertCount(2, $data['invoices']);
$invoice_report = $data['invoices'][1];
$this->assertEquals('deleted', $invoice_report[6]);
$this->assertEquals(-330, $invoice_report[2]); // Negative amount
$this->assertEquals(-330, $invoice_report[3]); // Negative paid_to_date
$this->assertEquals(-30, $invoice_report[4]); // Negative GST
$this->travelBack();
}
// ========================================
// PAYMENT DELETION TESTS
// ========================================
/**
* Test: Payment deleted in same period as payment (cash accounting)
* Expected: No net effect on that period
*/
public function testPaymentDeletedInSamePeriodCash()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'public_notes' => 'iamdeleted',
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->markPaid()->save();
$payment = $invoice->payments()->first();
// Delete payment in same period
$payment->service()->deletePayment();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
// No payment, no cash report entry
$this->assertCount(1, $data['invoices']); // Just header
$this->travelBack();
}
/**
* Test: Payment deleted in next period (cash accounting)
* Expected: Original period shows +tax, deletion period shows -tax adjustment
*/
public function testPaymentDeletedInNextPeriodCash()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'public_notes' => 'iamdeleted',
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->markPaid()->save();
// INVOICE PAID IN OCTOBER
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
//REPORTED IN OCTOBER
$payment = $invoice->payments()->first();
$this->assertNotNull($payment);
// Deleted IN NOVEMBER
$payment = $payment->service()->deletePayment();
$this->assertNotNull($payment);
$this->assertTrue($payment->is_deleted);
(new \App\Listeners\Payment\PaymentTransactionEventEntry(
$payment,
[$invoice->id],
$this->company->db,
0,
true
))->handle();
$payment_deleted_event = $invoice->fresh()->transaction_events()->where('event_id', 3)->first();
$this->assertNotNull($payment_deleted_event);
nlog($payment_deleted_event->toArray());
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
// October shows +$30 GST (payment received)
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$this->assertEquals(-30, $data['invoices'][1][4]); // +$30 GST
$this->travelBack();
}
/**
* Test: Payment deleted in next period (accrual accounting)
* Expected: No effect on accrual reports (accrual is based on invoice date, not payment)
*/
public function testPaymentDeletedInNextPeriodAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'public_notes' => 'iamdeleted',
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->markPaid()->save();
(new InvoiceTransactionEventEntry())->run($invoice);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
$payment = $invoice->payments()->first();
$payment->service()->deletePayment();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
// October accrual report should still show $30 GST (payment deletion doesn't affect accrual)
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true, // accrual
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$this->assertEquals(30, $data['invoices'][1][4]); // Still $30 GST
// November accrual report should have no entries (payment deletion doesn't create accrual event)
$payload['start_date'] = '2025-11-01';
$payload['end_date'] = '2025-11-30';
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
$this->assertCount(1, $data['invoices']); // Just header, no invoice events in November for accrual
$this->travelBack();
}
// ========================================
// CANCELLED INVOICE TESTS - CASH ACCOUNTING
// ========================================
/**
* Test: Invoice cancelled in same period (cash accounting)
* Expected: If unpaid, no transaction event. If paid, need reversal.
*/
public function testCancelledInvoiceInSamePeriodCash()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
// Cancel in same period (unpaid)
$invoice->service()->handleCancellation()->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
// Cash accounting: unpaid cancelled invoice = no tax liability
$this->assertCount(1, $data['invoices']); // Just header, no data
$this->travelBack();
}
/**
* Test: Invoice paid then cancelled in same period (cash accounting)
* Expected: No net effect (payment and cancellation offset)
*/
public function testCancelledPaidInvoiceInSamePeriodCash()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->markPaid()->save();
// Cancel after payment in same period
$invoice->fresh();
$invoice->service()->handleCancellation()->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: true);
$data = $pl->boot()->getData();
// Should show the payment event but cancellation offsets it
// The exact behavior depends on implementation
$this->assertIsArray($data['invoices']);
$this->travelBack();
}
/**
* Test: Invoice paid in one period, cancelled in next period (cash accounting)
* Expected: First period shows +tax (payment), second period shows -tax (cancellation reversal)
*
* A cancelled partially paid invoice - will not impact future reports.
*/
public function testCancelledPartiallyPaidInvoiceInNextPeriodCash()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->applyPaymentAmount(110, 'partial-payment')->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
// Check October report (should show payment)
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$this->assertEquals(10, $data['invoices'][1][4]); // +$30 GST from payment
// Move to next period and cancel
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$invoice->fresh();
$invoice->service()->handleCancellation()->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
// Check November report (should show reversal)
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
// Should show cancelled status with negative adjustment
$this->assertCount(1, $data['invoices']);
$this->travelBack();
}
/**
* Test: Invoice with partial payment then cancelled (cash accounting)
* Expected: Report taxes only on paid portion, reversal only affects paid amount
*
* TODO: Requires cancellation transaction events for cash accounting to be implemented
*/
public function testCancelledInvoiceWithPartialPaymentCash()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
// Pay half (165 = 50% of 330)
$invoice->service()->applyPaymentAmount(110, 'partial-payment')->save();
$invoice = $invoice->fresh();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
// Check October report (should show 50% of taxes)
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$this->assertEquals(10, $data['invoices'][1][4]); // +$15 GST (50% of $30)
// Move to next period and cancel
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$invoice->fresh();
$invoice->service()->handleCancellation()->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
// November report should show reversal of paid portion only
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
// Should show reversal of the 50% that was paid
$this->assertEquals(1, count($data['invoices']));
$this->travelBack();
}
// ========================================
// CREDIT NOTE / REVERSAL TESTS
// ========================================
/**
* Test: Invoice reversed with credit note in next period (accrual)
* Expected: Original period shows liability, reversal period shows negative adjustment
*
* TODO: Implement invoice reversal functionality via credit notes and transaction events
* This requires creating credits and ensuring they generate appropriate transaction events
*/
public function testInvoiceReversedWithCreditNoteNextPeriodAccrual()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
// Check October report
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true, // accrual
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$this->assertEquals(30, $data['invoices'][1][4]); // +$30 GST
// Move to next period and reverse
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$invoice->fresh();
// $invoice->service()->reverseInvoice()->save();
$reversal_payload = array_merge($invoice->toArray(), ['invoice_id' => $invoice->hashed_id, 'client_id' => $this->client->hashed_id]);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->_token,
])->postJson('/api/v1/credits', $reversal_payload);
$response->assertStatus(422);
$invoice = $invoice->fresh();
$this->assertEquals(Invoice::STATUS_SENT, $invoice->status_id);
$this->travelBack();
}
/**
* Test: Invoice paid then reversed with credit note (cash accounting)
* Expected: Payment period shows +tax, reversal period shows -tax
*
*/
public function testInvoiceReversedWithCreditNoteNextPeriodCash()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => '2025-10-01',
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->markPaid()->save();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay());
// Check October report (payment received)
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
nlog($data);
$this->assertCount(2, $data['invoices']);
$this->assertEquals(30, $data['invoices'][1][4]); // +$30 GST
// Move to next period and reverse
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
$invoice->fresh();
$reversal_payload = array_merge($invoice->toArray(), ['invoice_id' => $invoice->hashed_id, 'client_id' => $this->client->hashed_id]);
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->_token,
])->postJson('/api/v1/credits', $reversal_payload);
$response->assertStatus(200);
$credit = \App\Models\Credit::withTrashed()->where('invoice_id', $invoice->id)->first();
$invoice = $invoice->fresh();
$this->assertEquals(Invoice::STATUS_REVERSED, $invoice->status_id);
$this->assertNotNull($credit);
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
// Check November report (should show reversal)
$payload = [
'start_date' => '2025-11-01',
'end_date' => '2025-11-30',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$reversed_event = $invoice->fresh()->transaction_events()->where('metadata->tax_report->tax_summary->status', 'reversed')->first();
$this->assertNotNull($reversed_event);
$this->assertEquals('2025-11-30', $reversed_event->period->format('Y-m-d'));
nlog("2");
nlog($data);
// Should show reversal
$this->assertGreaterThanOrEqual(2, count($data['invoices']));
$this->travelBack();
}
// ========================================
// COMPLEX MULTI-PERIOD SCENARIOS
// ========================================
/**
* Test: Partial payment, then full refund across different periods
* Expected: Period 1 shows partial tax, Period 2 shows refund adjustment
*
* TODO: Fix tax calculation for partial payments in cash accounting.
* Currently shows full tax amount ($30) instead of proportional tax for partial payment ($15).
* The tax should be calculated based on the amount actually paid, not the full invoice amount.
*/
public function testPartialPaymentThenFullRefundAcrossPeriods()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 300;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
// Pay half in December
$invoice->service()->applyPaymentAmount(165, 'partial-payment')->save();
$invoice = $invoice->fresh();
$this->travelTo(\Carbon\Carbon::createFromDate(2026, 1, 1)->startOfDay());
// Check December (should show 50% of taxes)
$payload = [
'start_date' => '2025-12-01',
'end_date' => '2025-12-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
$this->assertCount(2, $data['invoices']);
$this->assertEquals(15, $data['invoices'][1][4]); // +$15 GST (50% of $30)
$this->assertEquals(150, $data['invoices'][1][5]); // +$15 GST (50% of $30)
// Refund the full partial payment in January
$payment = $invoice->payments()->first();
$refund_data = [
'id' => $payment->hashed_id,
'date' => '2026-01-15',
'invoices' => [
[
'invoice_id' => $invoice->hashed_id,
'amount' => 165, // Full refund of partial payment
],
]
];
$this->travelTo(\Carbon\Carbon::createFromDate(2026, 1, 15)->startOfDay());
$response = $this->withHeaders([
'X-API-SECRET' => config('ninja.api_secret'),
'X-API-TOKEN' => $this->_token,
])->postJson('/api/v1/payments/refund', $refund_data);
$response->assertStatus(200);
(new PaymentTransactionEventEntry($payment->refresh(), [$invoice->id], $payment->company->db, 165, false))->handle();
// nlog($invoice->fresh()->transaction_events()->where('event_id', 2)->first()->toArray());
$this->assertEquals(3, $invoice->fresh()->transaction_events()->count());
$this->travelTo(\Carbon\Carbon::createFromDate(2026, 2, 1)->startOfDay());
// Check January (should show -$15 reversal)
$payload = [
'start_date' => '2026-01-01',
'end_date' => '2026-01-31',
'date_range' => 'custom',
'is_income_billed' => false, // cash
];
$pl = new TaxPeriodReport($this->company, $payload, skip_initialization: false);
$data = $pl->boot()->getData();
// Should show negative adjustment
$this->assertGreaterThanOrEqual(1, count($data['invoices']));
$found = false;
foreach ($data['invoices'] as $idx => $row) {
if ($idx === 0) continue;
if (isset($row[4]) && $row[4] == -15) {
$found = true;
break;
}
}
$this->assertTrue($found, 'Refund adjustment not found in January report');
$this->travelBack();
}
/**
* Test: Invoice amount increased multiple times across different periods
* Expected: Each period shows the delta adjustment
*/
public function testInvoiceIncreasedMultipleTimesAcrossPeriods()
{
$this->buildData();
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 10, 1)->startOfDay());
$line_items = [];
$item = InvoiceItemFactory::create();
$item->quantity = 1;
$item->cost = 100;
$item->tax_name1 = 'GST';
$item->tax_rate1 = 10;
$line_items[] = $item;
$invoice = Invoice::factory()->create([
'client_id' => $this->client->id,
'company_id' => $this->company->id,
'user_id' => $this->user->id,
'line_items' => $line_items,
'status_id' => Invoice::STATUS_DRAFT,
'discount' => 0,
'is_amount_discount' => false,
'uses_inclusive_taxes' => false,
'tax_name1' => '',
'tax_rate1' => 0,
'tax_name2' => '',
'tax_rate2' => 0,
'tax_name3' => '',
'tax_rate3' => 0,
'custom_surcharge1' => 0,
'custom_surcharge2' => 0,
'custom_surcharge3' => 0,
'custom_surcharge4' => 0,
'date' => now()->format('Y-m-d'),
'due_date' => now()->addDays(30)->format('Y-m-d'),
]);
$invoice = $invoice->calc()->getInvoice();
$invoice->service()->markSent()->save();
(new InvoiceTransactionEventEntry())->run($invoice);
// October: Initial invoice $100 + $10 tax = $110
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 5)->startOfDay());
// Increase to $200
$line_items[0]->cost = 200;
$invoice->line_items = $line_items;
$invoice = $invoice->calc()->getInvoice();
(new InvoiceTransactionEventEntry())->run($invoice);
// November: Adjustment +$100 + $10 tax
$this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 5)->startOfDay());
// Increase to $300
$line_items[0]->cost = 300;
$invoice->line_items = $line_items;
$invoice = $invoice->calc()->getInvoice();
(new InvoiceTransactionEventEntry())->run($invoice);
// December: Adjustment +$100 + $10 tax
$this->travelTo(\Carbon\Carbon::createFromDate(2026, 1, 1)->startOfDay());
// Check October
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true,
];
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$this->assertEquals(10, $data['invoices'][1][4]); // $10 tax
// Check November
$payload['start_date'] = '2025-11-01';
$payload['end_date'] = '2025-11-30';
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$this->assertEquals(10, $data['invoices'][1][4]); // +$10 tax adjustment
// Check December
$payload['start_date'] = '2025-12-01';
$payload['end_date'] = '2025-12-31';
$pl = new TaxPeriodReport($this->company, $payload);
$data = $pl->boot()->getData();
$this->assertEquals(10, $data['invoices'][1][4]); // +$10 tax adjustment
$this->travelBack();
}
}