Improvements for tax reporting
This commit is contained in:
parent
2f8cf977b0
commit
6c09ed8a47
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,4 +67,5 @@ class TransactionEvent extends StaticModel
|
|||
public const PAYMENT_DELETED = 3;
|
||||
|
||||
public const PAYMENT_CASH = 4;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue