From 6c09ed8a47743b4fb7add90b0a0e6cbdbf279d47 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 20 Nov 2025 13:08:08 +1100 Subject: [PATCH] Improvements for tax reporting --- app/DataMapper/TaxReport/TaxSummary.php | 2 +- .../Invoice/InvoiceTransactionEventEntry.php | 70 +- .../InvoiceTransactionEventEntryCash.php | 21 +- .../Payment/PaymentTransactionEventEntry.php | 119 ++- app/Models/TransactionEvent.php | 1 + app/Services/Payment/RefundPayment.php | 2 +- .../Report/TaxPeriod/InvoiceReportRow.php | 1 + .../Report/TaxPeriod/TaxReportStatus.php | 2 + app/Services/Report/TaxPeriodReport.php | 27 +- lang/en/texts.php | 1 + tests/Feature/Export/TaxPeriodReportTest.php | 784 +++++++++++++++++- 11 files changed, 926 insertions(+), 104 deletions(-) diff --git a/app/DataMapper/TaxReport/TaxSummary.php b/app/DataMapper/TaxReport/TaxSummary.php index 15fd86dea9..d01b0a4c29 100644 --- a/app/DataMapper/TaxReport/TaxSummary.php +++ b/app/DataMapper/TaxReport/TaxSummary.php @@ -19,7 +19,7 @@ class TaxSummary { public float $taxable_amount; public float $total_taxes; // Tax collected and confirmed (ie. Invoice Paid) - public string $status; // updated, deleted, cancelled, adjustment + public string $status; // updated, deleted, cancelled, adjustment, reversed public float $adjustment; public float $tax_adjustment; diff --git a/app/Listeners/Invoice/InvoiceTransactionEventEntry.php b/app/Listeners/Invoice/InvoiceTransactionEventEntry.php index f8bf0de6fc..ccdd7f04d3 100644 --- a/app/Listeners/Invoice/InvoiceTransactionEventEntry.php +++ b/app/Listeners/Invoice/InvoiceTransactionEventEntry.php @@ -43,8 +43,6 @@ class InvoiceTransactionEventEntry $this->setPaidRatio($invoice); - // if($invoice->public_notes == 'iamdeleted') - // nlog($invoice->toArray()); //Long running tasks may spill over into the next day therefore month! $period = $force_period ?? now()->endOfMonth()->subHours(5)->format('Y-m-d'); @@ -66,11 +64,15 @@ class InvoiceTransactionEventEntry // Invoice was previously cancelled, and is still cancelled... return early!! return; } + else if(in_array($invoice->status_id,[Invoice::STATUS_REVERSED]) && $event->metadata->tax_report->tax_summary->status == 'reversed'){ + // Invoice was previously cancelled, and is still cancelled... return early!! + return; + } else if (!$invoice->is_deleted && $event->metadata->tax_report->tax_summary->status == 'deleted'){ //restored invoice must be reported!!!! _do not return early!! $this->entry_type = 'restored'; } - else if(in_array($invoice->status_id,[Invoice::STATUS_CANCELLED])){ + else if(in_array($invoice->status_id,[Invoice::STATUS_CANCELLED, Invoice::STATUS_REVERSED])){ // Need to ensure first time cancellations are reported. // return; // Only return if BOTH amount AND status unchanged - for handling cancellations. @@ -197,6 +199,51 @@ class InvoiceTransactionEventEntry } + private function getReversedMetaData($invoice) + { + $calc = $invoice->calc(); + + $details = []; + + $taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray()); + + //If there is a previous transaction event, we need to consider the taxable amount. + // $previous_transaction_event = TransactionEvent::where('event_id', TransactionEvent::INVOICE_UPDATED) + // ->where('invoice_id', $invoice->id) + // ->orderBy('timestamp', 'desc') + // ->first(); + + if($this->paid_ratio == 0){ + // setup a 0/0 recorded + } + + foreach ($taxes as $tax) { + $tax_detail = [ + 'tax_name' => $tax['name'], + 'tax_rate' => $tax['tax_rate'], + 'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio * -1, + 'tax_amount' => ($tax['total'] * $this->paid_ratio * -1), + ]; + $details[] = $tax_detail; + } + + //@todo what happens if this is triggered in the "NEXT FINANCIAL PERIOD? + return new TransactionEventMetadata([ + 'tax_report' => [ + 'tax_details' => $details, + 'payment_history' => $this->payments->toArray() ?? [], //@phpstan-ignore-line + 'tax_summary' => [ + 'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio * -1, + 'total_taxes' => $calc->getTotalTaxes() * $this->paid_ratio * -1, + 'status' => 'reversed', + // 'adjustment' => round($calc->getNetSubtotal() - $previous_transaction_event->metadata->tax_report->tax_summary->taxable_amount, 2), + // 'tax_adjustment' => round($calc->getTotalTaxes() - $previous_transaction_event->metadata->tax_report->tax_summary->total_taxes,2) + ], + ], + ]); + + } + /** * Existing tax details are not deleted, but pending taxes are set to 0 * @@ -212,21 +259,14 @@ class InvoiceTransactionEventEntry $taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray()); //If there is a previous transaction event, we need to consider the taxable amount. - $previous_transaction_event = TransactionEvent::where('event_id', TransactionEvent::INVOICE_UPDATED) - ->where('invoice_id', $invoice->id) - ->orderBy('timestamp', 'desc') - ->first(); + // $previous_transaction_event = TransactionEvent::where('event_id', TransactionEvent::INVOICE_UPDATED) + // ->where('invoice_id', $invoice->id) + // ->orderBy('timestamp', 'desc') + // ->first(); if($this->paid_ratio == 0){ // setup a 0/0 recorded } - - //If there are no previous events, we setup a 0/0 record. - - // If there is a previous event, it must have a payment history? - if($previous_transaction_event){ - $previous_tax_details = $previous_transaction_event->metadata->tax_report->tax_details; - } foreach ($taxes as $tax) { $tax_detail = [ @@ -300,6 +340,8 @@ class InvoiceTransactionEventEntry return $this->getCancelledMetaData($invoice); } elseif ($invoice->is_deleted) { return $this->getDeletedMetaData($invoice); + } elseif ($invoice->status_id == Invoice::STATUS_REVERSED){ + return $this->getReversedMetaData($invoice); } elseif ($this->entry_type == 'delta') { return $this->calculateDeltaMetaData($invoice); } diff --git a/app/Listeners/Invoice/InvoiceTransactionEventEntryCash.php b/app/Listeners/Invoice/InvoiceTransactionEventEntryCash.php index d3be68ede1..e7fff0585f 100644 --- a/app/Listeners/Invoice/InvoiceTransactionEventEntryCash.php +++ b/app/Listeners/Invoice/InvoiceTransactionEventEntryCash.php @@ -84,6 +84,7 @@ class InvoiceTransactionEventEntryCash $this->paid_ratio = $invoice->paid_to_date / $invoice->amount; + nlog("paid ratio => {$this->paid_ratio}"); return $this; } @@ -107,8 +108,6 @@ class InvoiceTransactionEventEntryCash 'tax_rate' => $tax['tax_rate'], 'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio, 'tax_amount' => $tax['total'] * $this->paid_ratio, - 'tax_amount_paid' => $this->calculateRatio($tax['total']), - 'tax_amount_remaining' => $tax['total'] - $this->calculateRatio($tax['total']), ]; $details[] = $tax_detail; } @@ -118,10 +117,9 @@ class InvoiceTransactionEventEntryCash 'tax_details' => $details, 'payment_history' => $this->payments->toArray(), 'tax_summary' => [ - 'total_taxes' => $invoice->total_taxes, - 'total_paid' => $this->getTotalTaxPaid($invoice), + 'total_taxes' => $invoice->total_taxes * $this->paid_ratio, 'status' => 'updated', - 'taxable_amount' => $calc->getNetSubtotal(), + 'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio, 'adjustment' => 0, 'tax_adjustment' => 0, ], @@ -130,17 +128,4 @@ class InvoiceTransactionEventEntryCash } - private function getTotalTaxPaid($invoice) - { - if ($invoice->amount == 0) { - return 0; - } - - $total_paid = $this->payments->sum('amount') - $this->payments->sum('refunded'); - - return round($invoice->total_taxes * ($total_paid / $invoice->amount), 2); - - } - - } diff --git a/app/Listeners/Payment/PaymentTransactionEventEntry.php b/app/Listeners/Payment/PaymentTransactionEventEntry.php index f2888edbdf..dc63d0c5f5 100644 --- a/app/Listeners/Payment/PaymentTransactionEventEntry.php +++ b/app/Listeners/Payment/PaymentTransactionEventEntry.php @@ -12,18 +12,19 @@ namespace App\Listeners\Payment; +use Carbon\Carbon; +use App\Utils\BcMath; use App\Models\Invoice; +use App\Models\Payment; +use App\Libraries\MultiDB; use App\Models\TransactionEvent; use Illuminate\Support\Collection; -use Illuminate\Contracts\Queue\ShouldQueue; -use App\DataMapper\TransactionEventMetadata; -use App\Libraries\MultiDB; -use App\Models\Payment; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; -use Carbon\Carbon; +use App\DataMapper\TransactionEventMetadata; +use Illuminate\Queue\Middleware\WithoutOverlapping; class PaymentTransactionEventEntry implements ShouldQueue { @@ -37,22 +38,30 @@ class PaymentTransactionEventEntry implements ShouldQueue private float $paid_ratio; + private float $refund_ratio = 0; + private Collection $payments; /** + * @param Payment $payment + * @param array $invoice_ids + * @param string $db + * @param mixed $invoice_adjustment - represents the differential amount (which could be variable and never a static known property value) + * @param bool $is_deleted */ public function __construct(private Payment $payment, private array $invoice_ids, private string $db, private mixed $invoice_adjustment = 0, private bool $is_deleted = false) {} public function handle() { + nlog("set invoice adjustment => {$this->invoice_adjustment}"); try{ $this->runLog(); } catch(\Throwable $e){ - nlog("PaymentTransactionEventEntry::handle"); + nlog("PaymentTransactionEventEntry::handle - ERROR"); nlog($e->getMessage()); - // nlog($e->getTraceAsString()); + nlog($e->getTraceAsString()); } } @@ -61,9 +70,13 @@ class PaymentTransactionEventEntry implements ShouldQueue //payment vs refunded MultiDB::setDb($this->db); - if($this->payment->invoices()->count() == 0) + if($this->payment->invoices()->count() == 0 && !$this->payment->is_deleted){ + nlog("PaymentTransactionEventEntry::runLog:: no invoices found"); return; + } + //consider deleted invoices!! the following will not hit. + $this->payments = $this->payment ->invoices() ->get() @@ -80,6 +93,7 @@ class PaymentTransactionEventEntry implements ShouldQueue ]; }); + Invoice::withTrashed() ->whereIn('id', $this->invoice_ids) ->get() @@ -130,11 +144,51 @@ class PaymentTransactionEventEntry implements ShouldQueue return $this; } - $this->paid_ratio = $invoice->paid_to_date / $invoice->amount; + // For refunds/deletions, the paid_to_date has already been decremented + // So we need to add back the refund amount to get the PREVIOUS paid_to_date + $paid_to_date_for_ratio = $invoice->paid_to_date; + if ($this->invoice_adjustment > 0) { + $paid_to_date_for_ratio += $this->invoice_adjustment; + } + + $this->paid_ratio = $paid_to_date_for_ratio / $invoice->amount; return $this; } + private function getRefundRatio(Invoice $invoice): float + { + // For partial refunds, calculate ratio based on what was previously paid + // Get the previous transaction event to find the historical paid_to_date + if ($this->invoice_adjustment <= 0) { + return 0; + } + + // Get the most recent transaction event to see what was previously recorded + $previous_event = $invoice->transaction_events() + ->orderBy('id', 'desc') + ->first(); + + if ($previous_event && $previous_event->invoice_paid_to_date > 0) { + // Ratio: refund_amount / previous_paid_to_date + // This gives us the portion of the previous payment being refunded + nlog("Using previous event: refund {$this->invoice_adjustment} / {$previous_event->invoice_paid_to_date}"); + return $this->invoice_adjustment / $previous_event->invoice_paid_to_date; + } + + // Fallback: calculate what paid_to_date was BEFORE this refund + // Since the refund has already been processed, paid_to_date is already reduced + // So: paid_to_date_before_refund = current_paid_to_date + refund_amount + $paid_to_date_before = $invoice->paid_to_date + $this->invoice_adjustment; + + if ($paid_to_date_before > 0) { + nlog("No previous event: refund {$this->invoice_adjustment} / {$paid_to_date_before}"); + return $this->invoice_adjustment / $paid_to_date_before; + } + + return 0; + } + private function calculateRatio(float $amount): float { return round($amount * $this->paid_ratio, 2); @@ -143,6 +197,9 @@ class PaymentTransactionEventEntry implements ShouldQueue /** * Existing tax details are not deleted, but pending taxes are set to 0 * + * For partial refunds, uses pro-rata calculation based on refund amount / invoice amount + * For full refunds, uses paid_ratio (payment amount / invoice amount) + * * @param mixed $invoice */ private function getRefundedMetaData($invoice) @@ -154,6 +211,10 @@ class PaymentTransactionEventEntry implements ShouldQueue $taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray()); + // For full refunds, use the paid ratio (amount paid / invoice amount) + // This gives us the correct tax adjustment + $ratio = $this->paid_ratio; + foreach ($taxes as $tax) { $base_amount = $tax['base_amount'] ?? $calc->getNetSubtotal(); @@ -161,15 +222,9 @@ class PaymentTransactionEventEntry implements ShouldQueue $tax_detail = [ 'tax_name' => $tax['name'], 'tax_rate' => $tax['tax_rate'], - 'taxable_amount' => $base_amount * $this->paid_ratio, - 'tax_amount' => $tax['total'] * $this->paid_ratio, - 'tax_amount_paid' => $this->calculateRatio($tax['total']), - 'tax_amount_remaining' => round($tax['total'] - $this->calculateRatio($tax['total']), 2), - 'taxable_amount_adjustment' => ($base_amount * $this->paid_ratio) - $base_amount, - 'tax_amount_adjustment' => ($tax['total'] * $this->paid_ratio) - $tax['total'], - 'tax_amount_paid_adjustment' => ($tax['total'] * $this->paid_ratio) - $tax['total'], - 'tax_amount_remaining_adjustment' => round($tax['total'] - $this->calculateRatio($tax['total']) - ($tax['total'] * $this->paid_ratio), 2), - ]; + 'taxable_amount' => round($base_amount * $ratio, 2) * -1, + 'tax_amount' => round($tax['total'] * $ratio, 2) * -1, + ]; $details[] = $tax_detail; } @@ -178,11 +233,10 @@ class PaymentTransactionEventEntry implements ShouldQueue 'tax_details' => $details, 'payment_history' => $this->payments->toArray(), 'tax_summary' => [ - 'total_taxes' => round($invoice->total_taxes - $this->getTotalTaxPaid($invoice), 2) * -1, - 'total_paid' => 0, - 'tax_adjustment' => round($invoice->total_taxes - $this->getTotalTaxPaid($invoice), 2) * -1, + 'total_taxes' => round(($invoice->total_taxes - $this->getTotalTaxPaid($invoice)) * $ratio, 2) * -1, + 'tax_adjustment' => round(($invoice->total_taxes - $this->getTotalTaxPaid($invoice)) * $ratio, 2) * -1, 'status' => 'adjustment', - 'taxable_amount' => ($calc->getNetSubtotal() * $this->paid_ratio) - $calc->getNetSubtotal(), + 'taxable_amount' => round($calc->getNetSubtotal() * $ratio, 2) * -1, 'adjustment' => 0, ], ], @@ -207,20 +261,12 @@ class PaymentTransactionEventEntry implements ShouldQueue foreach ($taxes as $tax) { $base_amount = $tax['base_amount'] ?? $calc->getNetSubtotal(); - - if($this->invoice_adjustment > 0) - $tax_amount_paid = round(($this->invoice_adjustment / ($base_amount+$tax['total'])) * $tax['total'], 2); - else { - $tax_amount_paid = $this->calculateRatio($tax['total']); - } $tax_detail = [ 'tax_name' => $tax['name'], 'tax_rate' => $tax['tax_rate'], - 'taxable_amount' => $base_amount, - 'tax_amount' => $tax['total'], - 'tax_amount_paid' => $tax_amount_paid, - 'tax_amount_remaining' => 0, + 'taxable_amount' => $base_amount * -1, + 'tax_amount' => $tax['total'] * -1, 'tax_status' => 'payment_deleted', ]; @@ -232,9 +278,8 @@ class PaymentTransactionEventEntry implements ShouldQueue 'tax_details' => $details, 'payment_history' => $this->payments->toArray(), 'tax_summary' => [ - 'total_taxes' => $invoice->total_taxes, - 'total_paid' => $this->getTotalTaxPaid($invoice), - 'taxable_amount' => $calc->getNetSubtotal(), + 'total_taxes' => $invoice->total_taxes * -1, + 'taxable_amount' => $calc->getNetSubtotal() * -1, 'adjustment' => 0, 'tax_adjustment' => round($invoice->total_taxes - $this->getTotalTaxPaid($invoice), 2) * -1, 'status' => 'adjustment', @@ -263,6 +308,8 @@ class PaymentTransactionEventEntry implements ShouldQueue $total_paid = $this->payments->sum('amount') - $this->payments->sum('refunded'); + nlog("total paid => {$total_paid} - total taxes => {$invoice->total_taxes} - amount => {$invoice->amount}"); + return round($invoice->total_taxes * ($total_paid / $invoice->amount), 2); } diff --git a/app/Models/TransactionEvent.php b/app/Models/TransactionEvent.php index 73e210862e..c63fedbbd5 100644 --- a/app/Models/TransactionEvent.php +++ b/app/Models/TransactionEvent.php @@ -67,4 +67,5 @@ class TransactionEvent extends StaticModel public const PAYMENT_DELETED = 3; public const PAYMENT_CASH = 4; + } diff --git a/app/Services/Payment/RefundPayment.php b/app/Services/Payment/RefundPayment.php index 9b86522c3d..f05991674d 100644 --- a/app/Services/Payment/RefundPayment.php +++ b/app/Services/Payment/RefundPayment.php @@ -338,7 +338,7 @@ class RefundPayment } - PaymentTransactionEventEntry::dispatch($this->payment, array_column($this->refund_data['invoices'], 'invoice_id'), $this->payment->company->db, 0, false); + PaymentTransactionEventEntry::dispatch($this->payment, array_column($this->refund_data['invoices'], 'invoice_id'), $this->payment->company->db, $this->total_refund, false); } else { //if we are refunding and no payments have been tagged, then we need to decrement the client->paid_to_date by the total refund amount. diff --git a/app/Services/Report/TaxPeriod/InvoiceReportRow.php b/app/Services/Report/TaxPeriod/InvoiceReportRow.php index cbab63b188..bfe917f2ee 100644 --- a/app/Services/Report/TaxPeriod/InvoiceReportRow.php +++ b/app/Services/Report/TaxPeriod/InvoiceReportRow.php @@ -168,6 +168,7 @@ class InvoiceReportRow TaxReportStatus::CANCELLED => $this->buildCancelledRow(), TaxReportStatus::DELETED => $this->buildDeletedRow(), TaxReportStatus::RESTORED => $this->buildUpdatedRow(), // Treat restored as updated + TaxReportStatus::REVERSED => $this->buildDeltaRow(), }; } diff --git a/app/Services/Report/TaxPeriod/TaxReportStatus.php b/app/Services/Report/TaxPeriod/TaxReportStatus.php index 987686bf66..0139e031f2 100644 --- a/app/Services/Report/TaxPeriod/TaxReportStatus.php +++ b/app/Services/Report/TaxPeriod/TaxReportStatus.php @@ -23,6 +23,7 @@ enum TaxReportStatus: string case CANCELLED = 'cancelled'; case DELETED = 'deleted'; case RESTORED = 'restored'; + case REVERSED = 'reversed'; /** * Get human-readable label for the status @@ -36,6 +37,7 @@ enum TaxReportStatus: string self::CANCELLED => 'cancelled', self::DELETED => 'deleted', self::RESTORED => 'restored', + self::REVERSED => 'reversed', }; } diff --git a/app/Services/Report/TaxPeriodReport.php b/app/Services/Report/TaxPeriodReport.php index eb7a830fd3..d3ea0d6080 100644 --- a/app/Services/Report/TaxPeriodReport.php +++ b/app/Services/Report/TaxPeriodReport.php @@ -120,6 +120,8 @@ class TaxPeriodReport extends BaseExport { $this->cash_accounting = $this->input['is_income_billed'] ? false : true; + nlog("IS CASH ACCOUNTING ? => {$this->cash_accounting}"); + return $this; } @@ -135,7 +137,7 @@ class TaxPeriodReport extends BaseExport { $q = Invoice::withTrashed() ->where('company_id', $this->company->id) - ->whereIn('status_id', [2,3,4,5]) + ->whereIn('status_id', [2,3,4,5,6]) ->whereBetween('date', ['1970-01-01', now()->subMonth()->endOfMonth()->format('Y-m-d')]) ->whereDoesntHave('transaction_events'); @@ -167,7 +169,7 @@ class TaxPeriodReport extends BaseExport $query->where('period', '<=', $this->end_date); }) ->where(function ($q) { - $q->whereIn('status_id', [Invoice::STATUS_CANCELLED]) + $q->whereIn('status_id', [Invoice::STATUS_CANCELLED, Invoice::STATUS_REVERSED]) ->orWhere('is_deleted', true); }) ->whereDoesntHave('transaction_events', function ($query) { @@ -179,7 +181,9 @@ class TaxPeriodReport extends BaseExport $ii->cursor() ->each(function ($invoice) { + (new InvoiceTransactionEventEntry())->run($invoice, $this->end_date); + }); return $this; @@ -198,10 +202,13 @@ class TaxPeriodReport extends BaseExport if ($this->cash_accounting) { //cash - $query->whereIn('status_id', [3,4]) + $query->whereIn('status_id', [2,3,4,5,6]) ->whereHas('transaction_events', function ($query) { - $query->where('event_id', '!=', TransactionEvent::INVOICE_UPDATED) - ->whereBetween('period', [$this->start_date, $this->end_date]); + $query->where(function ($sub_q){ + $sub_q->where('event_id', '!=', TransactionEvent::INVOICE_UPDATED) + ->orWhere('metadata->tax_report->tax_summary->status', 'reversed'); + + })->whereBetween('period', [$this->start_date, $this->end_date]); }); } else { //accrual @@ -391,13 +398,20 @@ class TaxPeriodReport extends BaseExport $query->where('event_id', TransactionEvent::INVOICE_UPDATED); }) ->when($this->cash_accounting, function ($query) { - $query->where('event_id', '!=', TransactionEvent::INVOICE_UPDATED); + $query->where(function ($sub_q){ + $sub_q->where('event_id', '!=', TransactionEvent::INVOICE_UPDATED) + ->orWhere('metadata->tax_report->tax_summary->status', 'reversed'); + + }); + + // $query->where('event_id', '!=', TransactionEvent::INVOICE_UPDATED); }) ->whereBetween('period', [$this->start_date, $this->end_date]) ->orderBy('timestamp', 'desc') ->cursor() ->each(function ($event) use ($invoice) { + /** @var Invoice $invoice */ $this->processTransactionEvent($event, $invoice); }); @@ -413,6 +427,7 @@ class TaxPeriodReport extends BaseExport { $tax_summary = TaxSummary::fromMetadata($event->metadata->tax_report->tax_summary); + nlog($event->metadata->toArray()); // Build and add invoice row $invoice_row_builder = new InvoiceReportRow( $invoice, diff --git a/lang/en/texts.php b/lang/en/texts.php index da5e5277db..c756f2a6b4 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -5662,6 +5662,7 @@ $lang = array( 'actual_delivery_date_help' => 'Sometimes required when billing across borders. Defines the EXACT date of delivery of goods.', 'invoice_period' => 'Invoice Period', 'invoice_period_help' => 'Defines the time period for which the services were provided.', + 'paused_recurring_invoice_helper' => 'Caution! When restarting a recurring invoice, ensure the next send date is in the future.', ); return $lang; diff --git a/tests/Feature/Export/TaxPeriodReportTest.php b/tests/Feature/Export/TaxPeriodReportTest.php index d838a285a7..76293dc381 100644 --- a/tests/Feature/Export/TaxPeriodReportTest.php +++ b/tests/Feature/Export/TaxPeriodReportTest.php @@ -50,8 +50,6 @@ class TaxPeriodReportTest extends TestCase ThrottleRequests::class ); - $this->withoutExceptionHandling(); - } public $company; @@ -763,14 +761,12 @@ class TaxPeriodReportTest extends TestCase $invoice = $invoice->fresh(); $payment = $invoice->payments()->first(); - (new PaymentTransactionEventEntry($payment, [$invoice->id], $payment->company->db, 0, false))->handle(); + (new PaymentTransactionEventEntry($payment, [$invoice->id], $payment->company->db, 110, false))->handle(); $this->travelTo(\Carbon\Carbon::createFromDate(2025, 12, 02)->startOfDay()); $invoice = $invoice->fresh(); - nlog($invoice->transaction_events()->where('event_id', 2)->first()->toArray()); - //cash should have NONE $payload = [ 'start_date' => '2025-11-01', @@ -782,6 +778,8 @@ class TaxPeriodReportTest extends TestCase $pl = new TaxPeriodReport($this->company, $payload); $data = $pl->boot()->getData(); + + // nlog($invoice->fresh()->transaction_events()->get()->toArray()); // nlog($data); $this->assertCount(2, $data['invoices']); @@ -1408,6 +1406,8 @@ class TaxPeriodReportTest extends TestCase $invoice = $invoice->calc()->getInvoice(); $invoice->service()->markSent()->markPaid()->save(); + // INVOICE PAID IN OCTOBER + $this->travelTo(\Carbon\Carbon::createFromDate(2025, 11, 1)->startOfDay()); $payload = [ @@ -1422,14 +1422,16 @@ class TaxPeriodReportTest extends TestCase $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); - // Delete payment in next period - $payment->service()->deletePayment(); - - $this->assertNotNull($payment); + $this->assertTrue($payment->is_deleted); (new \App\Listeners\Payment\PaymentTransactionEventEntry( $payment, @@ -1438,17 +1440,19 @@ class TaxPeriodReportTest extends TestCase 0, true ))->handle(); - - nlog($invoice->fresh()->transaction_events()->where('event_id', 3)->get()->toArray()); - $this->assertNotNull($invoice->fresh()->transaction_events()->where('event_id', 3)->first()); + $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-31', + 'end_date' => '2025-11-30', 'date_range' => 'custom', 'is_income_billed' => false, // cash ]; @@ -1456,22 +1460,8 @@ class TaxPeriodReportTest extends TestCase $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 - - // November shows -$30 GST (payment deleted) - $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(); - - $this->assertCount(2, $data['invoices']); - $invoice_report = $data['invoices'][1]; - - $this->assertEquals('adjustment', $invoice_report[6]); - $this->assertEquals(-30, $invoice_report[4]); // -$30 tax adjustment + $this->assertEquals(-30, $data['invoices'][1][4]); // +$30 GST $this->travelBack(); } @@ -1554,4 +1544,742 @@ class TaxPeriodReportTest extends TestCase $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(); + } } \ No newline at end of file