Improvements for tax reporting

This commit is contained in:
David Bomba 2025-11-21 09:08:09 +11:00
parent 6c09ed8a47
commit 694f1476de
14 changed files with 744 additions and 463 deletions

View File

@ -27,8 +27,9 @@ class TaxDetail
// Adjustment-specific fields (used when tax_status is "adjustment") // Adjustment-specific fields (used when tax_status is "adjustment")
public ?string $adjustment_reason; // "invoice_cancelled", "tax_rate_change", "exemption_applied", "correction" public ?string $adjustment_reason; // "invoice_cancelled", "tax_rate_change", "exemption_applied", "correction"
public float $taxable_amount_adjustment; public float $line_total;
public float $tax_amount_adjustment; public float $total_tax;
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
{ {
@ -42,8 +43,8 @@ class TaxDetail
// Adjustment fields // Adjustment fields
$this->adjustment_reason = $attributes['adjustment_reason'] ?? null; $this->adjustment_reason = $attributes['adjustment_reason'] ?? null;
$this->taxable_amount_adjustment = $attributes['taxable_amount_adjustment'] ?? 0.0; $this->line_total = $attributes['line_total'] ?? 0.0;
$this->tax_amount_adjustment = $attributes['tax_amount_adjustment'] ?? 0.0; $this->total_tax = $attributes['total_tax'] ?? 0.0;
} }
public function toArray(): array public function toArray(): array
@ -57,8 +58,8 @@ class TaxDetail
'tax_amount' => $this->tax_amount, 'tax_amount' => $this->tax_amount,
'tax_status' => $this->tax_status, 'tax_status' => $this->tax_status,
'adjustment_reason' => $this->adjustment_reason, 'adjustment_reason' => $this->adjustment_reason,
'taxable_amount_adjustment' => $this->taxable_amount_adjustment, 'line_total' => $this->line_total,
'tax_amount_adjustment' => $this->tax_amount_adjustment, 'total_tax' => $this->total_tax,
]; ];
return $data; return $data;

View File

@ -14,32 +14,37 @@ namespace App\DataMapper\TaxReport;
/** /**
* Tax summary with totals for different tax states * Tax summary with totals for different tax states
*
* Represents the taxable amount and tax amount for an invoice in a specific period.
* The meaning of these values depends on the status:
*
* - 'updated': Full invoice tax liability (accrual) or paid tax (cash)
* - 'delta': Differential tax change from invoice updates
* - 'adjustment': Tax change from payment refunds/deletions
* - 'cancelled': Proportional tax on refunded/cancelled amount
* - 'deleted': Full tax reversal
* - 'reversed': Full tax reversal of credit note
*/ */
class TaxSummary class TaxSummary
{ {
public float $taxable_amount; public float $taxable_amount;
public float $total_taxes; // Tax collected and confirmed (ie. Invoice Paid) public float $tax_amount;
public string $status; // updated, deleted, cancelled, adjustment, reversed public string $status;
public float $adjustment;
public float $tax_adjustment;
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
{ {
$this->taxable_amount = $attributes['taxable_amount'] ?? 0.0; $this->taxable_amount = $attributes['taxable_amount'] ?? 0.0;
$this->total_taxes = $attributes['total_taxes'] ?? 0.0; // Support both old and new property names for backwards compatibility during migration
$this->tax_amount = $attributes['tax_amount'] ?? $attributes['tax_adjustment'] ?? $attributes['total_taxes'] ?? 0.0;
$this->status = $attributes['status'] ?? 'updated'; $this->status = $attributes['status'] ?? 'updated';
$this->adjustment = $attributes['adjustment'] ?? 0.0;
$this->tax_adjustment = $attributes['tax_adjustment'] ?? 0.0;
} }
public function toArray(): array public function toArray(): array
{ {
return [ return [
'taxable_amount' => $this->taxable_amount, 'taxable_amount' => $this->taxable_amount,
'total_taxes' => $this->total_taxes, 'tax_amount' => $this->tax_amount,
'status' => $this->status, 'status' => $this->status,
'adjustment' => $this->adjustment,
'tax_adjustment' => $this->tax_adjustment,
]; ];
} }
} }

View File

@ -171,8 +171,8 @@ class InvoiceTaxSummary implements ShouldQueue
// }) // })
->whereBetween('updated_at', [$startDateUtc, $endDateUtc]) ->whereBetween('updated_at', [$startDateUtc, $endDateUtc])
->cursor() ->cursor()
->each(function (Invoice $invoice) { ->each(function (Invoice $invoice) use ($endDate) {
(new InvoiceTransactionEventEntry())->run($invoice); (new InvoiceTransactionEventEntry())->run($invoice, $endDate);
}); });
Invoice::withTrashed() Invoice::withTrashed()

View File

@ -38,6 +38,7 @@ class InvoiceTransactionEventEntry
*/ */
public function run(?Invoice $invoice, ?string $force_period = null) public function run(?Invoice $invoice, ?string $force_period = null)
{ {
if(!$invoice) if(!$invoice)
return; return;
@ -55,7 +56,13 @@ class InvoiceTransactionEventEntry
$this->entry_type = 'delta'; $this->entry_type = 'delta';
// nlog($event->period->format('Y-m-d') . " ". $period);
// if($force_period && $event->period->format('Y-m-d') == $force_period){
// nlog("already have an event!!");
// return;
// }
// else
if($invoice->is_deleted && $event->metadata->tax_report->tax_summary->status == 'deleted'){ if($invoice->is_deleted && $event->metadata->tax_report->tax_summary->status == 'deleted'){
// Invoice was previously deleted, and is still deleted... return early!! // Invoice was previously deleted, and is still deleted... return early!!
return; return;
@ -82,7 +89,10 @@ class InvoiceTransactionEventEntry
} }
/** If the invoice hasn't changed its state... return early!! */ /** If the invoice hasn't changed its state... return early!! */
else if(BcMath::comp($invoice->amount, $event->invoice_amount) == 0){ else if(BcMath::comp($invoice->amount, $event->invoice_amount) == 0 || $event->period->format('Y-m-d') == $period){
nlog("event period => {$period} => " . $event->period->format('Y-m-d'));
nlog("invoice amount => {$invoice->amount} => " . $event->invoice_amount);
nlog("apparently no change in amount or period");
return; return;
} }
@ -94,6 +104,7 @@ class InvoiceTransactionEventEntry
} }
nlog("invoice amount => {$invoice->amount}");
$this->payments = $invoice->payments->flatMap(function ($payment) { $this->payments = $invoice->payments->flatMap(function ($payment) {
return $payment->invoices()->get()->map(function ($invoice) use ($payment) { return $payment->invoices()->get()->map(function ($invoice) use ($payment) {
return [ return [
@ -134,11 +145,6 @@ class InvoiceTransactionEventEntry
return $this; return $this;
} }
private function calculateRatio(float $amount): float
{
return round($amount * $this->paid_ratio, 2);
}
/** /**
* calculateDeltaMetaData * calculateDeltaMetaData
@ -157,7 +163,6 @@ class InvoiceTransactionEventEntry
$details = []; $details = [];
$taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray()); $taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray());
$previous_transaction_event = TransactionEvent::where('event_id', TransactionEvent::INVOICE_UPDATED) $previous_transaction_event = TransactionEvent::where('event_id', TransactionEvent::INVOICE_UPDATED)
->where('invoice_id', $invoice->id) ->where('invoice_id', $invoice->id)
->orderBy('timestamp', 'desc') ->orderBy('timestamp', 'desc')
@ -168,31 +173,48 @@ class InvoiceTransactionEventEntry
foreach ($taxes as $tax) { foreach ($taxes as $tax) {
$previousLine = collect($previous_tax_details)->where('tax_name', $tax['name'])->first() ?? null; $previousLine = collect($previous_tax_details)->where('tax_name', $tax['name'])->first() ?? null;
nlog($previousLine);
nlog($tax);
$tax_detail = [ $tax_detail = [
'tax_name' => $tax['name'], 'tax_name' => $tax['name'],
'tax_rate' => $tax['tax_rate'], 'tax_rate' => $tax['tax_rate'],
'taxable_amount' => $tax['base_amount'] ?? $calc->getNetSubtotal(), 'line_total' => $tax['base_amount'],
'tax_amount' => $this->calculateRatio($tax['total']), 'total_tax' => $tax['total'],
'taxable_amount_adjustment' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) - ($previousLine->taxable_amount ?? 0), 'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) - ($previousLine->line_total ?? 0),
'tax_amount_adjustment' => $tax['total'] - ($previousLine->tax_amount ?? 0), 'tax_amount' => $tax['total'] - ($previousLine->total_tax ?? 0),
// 'taxable_amount_adjustment' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) - ($previousLine->taxable_amount ?? 0),
// 'tax_amount_adjustment' => $tax['total'] - ($previousLine->tax_amount ?? 0),
]; ];
nlog($tax_detail);
$details[] = $tax_detail; $details[] = $tax_detail;
} }
$this->setPaidRatio($invoice); $this->setPaidRatio($invoice);
// Calculate cumulative previous tax by summing all previous event tax amounts
$all_events = TransactionEvent::where('event_id', TransactionEvent::INVOICE_UPDATED)
->where('invoice_id', $invoice->id)
->orderBy('timestamp', 'asc')
->get();
$cumulative_tax = 0;
$cumulative_taxable = 0;
foreach ($all_events as $event) {
$cumulative_tax += $event->metadata->tax_report->tax_summary->tax_amount ?? 0;
$cumulative_taxable += $event->metadata->tax_report->tax_summary->taxable_amount ?? 0;
}
return new TransactionEventMetadata([ return new TransactionEventMetadata([
'tax_report' => [ 'tax_report' => [
'tax_details' => $details, 'tax_details' => $details,
'payment_history' => $this->payments->toArray() ?? [], //@phpstan-ignore-line 'payment_history' => $this->payments->toArray() ?? [], //@phpstan-ignore-line
'tax_summary' => [ 'tax_summary' => [
'taxable_amount' => $calc->getNetSubtotal(), 'taxable_amount' => $calc->getNetSubtotal() - $cumulative_taxable,
'total_taxes' => $calc->getTotalTaxes(), 'tax_amount' => round($calc->getTotalTaxes() - $cumulative_tax, 2),
'status' => 'delta', 'status' => 'delta',
'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)
], ],
], ],
]); ]);
@ -223,6 +245,8 @@ class InvoiceTransactionEventEntry
'tax_rate' => $tax['tax_rate'], 'tax_rate' => $tax['tax_rate'],
'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio * -1, 'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio * -1,
'tax_amount' => ($tax['total'] * $this->paid_ratio * -1), 'tax_amount' => ($tax['total'] * $this->paid_ratio * -1),
'line_total' => $tax['base_amount'] * $this->paid_ratio * -1,
'total_tax' => $tax['total'] * $this->paid_ratio * -1,
]; ];
$details[] = $tax_detail; $details[] = $tax_detail;
} }
@ -234,10 +258,8 @@ class InvoiceTransactionEventEntry
'payment_history' => $this->payments->toArray() ?? [], //@phpstan-ignore-line 'payment_history' => $this->payments->toArray() ?? [], //@phpstan-ignore-line
'tax_summary' => [ 'tax_summary' => [
'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio * -1, 'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio * -1,
'total_taxes' => $calc->getTotalTaxes() * $this->paid_ratio * -1, 'tax_amount' => $calc->getTotalTaxes() * $this->paid_ratio * -1,
'status' => 'reversed', '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)
], ],
], ],
]); ]);
@ -274,6 +296,8 @@ class InvoiceTransactionEventEntry
'tax_rate' => $tax['tax_rate'], 'tax_rate' => $tax['tax_rate'],
'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio, 'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * $this->paid_ratio,
'tax_amount' => ($tax['total'] * $this->paid_ratio), 'tax_amount' => ($tax['total'] * $this->paid_ratio),
'line_total' => $tax['base_amount'] * $this->paid_ratio,
'total_tax' => $tax['total'] * $this->paid_ratio,
]; ];
$details[] = $tax_detail; $details[] = $tax_detail;
} }
@ -285,10 +309,8 @@ class InvoiceTransactionEventEntry
'payment_history' => $this->payments->toArray() ?? [], //@phpstan-ignore-line 'payment_history' => $this->payments->toArray() ?? [], //@phpstan-ignore-line
'tax_summary' => [ 'tax_summary' => [
'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio, 'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio,
'total_taxes' => $calc->getTotalTaxes() * $this->paid_ratio, 'tax_amount' => $calc->getTotalTaxes() * $this->paid_ratio,
'status' => 'cancelled', 'status' => 'cancelled',
// '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)
], ],
], ],
]); ]);
@ -315,6 +337,8 @@ class InvoiceTransactionEventEntry
'tax_rate' => $tax['tax_rate'], 'tax_rate' => $tax['tax_rate'],
'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * -1, 'taxable_amount' => ($tax['base_amount'] ?? $calc->getNetSubtotal()) * -1,
'tax_amount' => $tax['total'] * -1, 'tax_amount' => $tax['total'] * -1,
'line_total' => $tax['base_amount'] * -1,
'total_tax' => $tax['total'] * -1,
]; ];
$details[] = $tax_detail; $details[] = $tax_detail;
} }
@ -324,8 +348,8 @@ class InvoiceTransactionEventEntry
'tax_details' => $details, 'tax_details' => $details,
'payment_history' => $this->payments->toArray(), 'payment_history' => $this->payments->toArray(),
'tax_summary' => [ 'tax_summary' => [
'taxable_amount' => $calc->getNetSubtotal(), 'taxable_amount' => $calc->getNetSubtotal() * -1,
'total_taxes' => $calc->getTotalTaxes(), 'tax_amount' => $calc->getTotalTaxes() * -1,
'status' => 'deleted', 'status' => 'deleted',
], ],
], ],
@ -358,6 +382,8 @@ class InvoiceTransactionEventEntry
'tax_rate' => $tax['tax_rate'], 'tax_rate' => $tax['tax_rate'],
'taxable_amount' => $tax['base_amount'] ?? $calc->getNetSubtotal(), 'taxable_amount' => $tax['base_amount'] ?? $calc->getNetSubtotal(),
'tax_amount' => $tax['total'], 'tax_amount' => $tax['total'],
'line_total' => $tax['base_amount'] ?? $calc->getNetSubtotal(),
'total_tax' => $tax['total'],
]; ];
$details[] = $tax_detail; $details[] = $tax_detail;
} }
@ -368,7 +394,7 @@ class InvoiceTransactionEventEntry
'payment_history' => $this->payments->toArray(), 'payment_history' => $this->payments->toArray(),
'tax_summary' => [ 'tax_summary' => [
'taxable_amount' => $calc->getNetSubtotal(), 'taxable_amount' => $calc->getNetSubtotal(),
'total_taxes' => $calc->getTotalTaxes(), 'tax_amount' => $calc->getTotalTaxes(),
'status' => 'updated', 'status' => 'updated',
], ],
], ],

View File

@ -117,11 +117,9 @@ class InvoiceTransactionEventEntryCash
'tax_details' => $details, 'tax_details' => $details,
'payment_history' => $this->payments->toArray(), 'payment_history' => $this->payments->toArray(),
'tax_summary' => [ 'tax_summary' => [
'total_taxes' => $invoice->total_taxes * $this->paid_ratio, 'tax_amount' => $invoice->total_taxes * $this->paid_ratio,
'status' => 'updated', 'status' => 'updated',
'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio, 'taxable_amount' => $calc->getNetSubtotal() * $this->paid_ratio,
'adjustment' => 0,
'tax_adjustment' => 0,
], ],
], ],
]); ]);

View File

@ -211,9 +211,9 @@ class PaymentTransactionEventEntry implements ShouldQueue
$taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray()); $taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray());
// For full refunds, use the paid ratio (amount paid / invoice amount) // Calculate refund ratio: refund_amount / invoice_amount
// This gives us the correct tax adjustment // This gives us the pro-rata portion of taxes to refund
$ratio = $this->paid_ratio; $refund_ratio = $invoice->amount > 0 ? $this->invoice_adjustment / $invoice->amount : 0;
foreach ($taxes as $tax) { foreach ($taxes as $tax) {
@ -222,8 +222,8 @@ class PaymentTransactionEventEntry implements ShouldQueue
$tax_detail = [ $tax_detail = [
'tax_name' => $tax['name'], 'tax_name' => $tax['name'],
'tax_rate' => $tax['tax_rate'], 'tax_rate' => $tax['tax_rate'],
'taxable_amount' => round($base_amount * $ratio, 2) * -1, 'taxable_amount' => round($base_amount * $refund_ratio, 2) * -1,
'tax_amount' => round($tax['total'] * $ratio, 2) * -1, 'tax_amount' => round($tax['total'] * $refund_ratio, 2) * -1,
]; ];
$details[] = $tax_detail; $details[] = $tax_detail;
} }
@ -233,11 +233,9 @@ class PaymentTransactionEventEntry implements ShouldQueue
'tax_details' => $details, 'tax_details' => $details,
'payment_history' => $this->payments->toArray(), 'payment_history' => $this->payments->toArray(),
'tax_summary' => [ 'tax_summary' => [
'total_taxes' => round(($invoice->total_taxes - $this->getTotalTaxPaid($invoice)) * $ratio, 2) * -1, 'tax_amount' => round($invoice->total_taxes * $refund_ratio, 2) * -1,
'tax_adjustment' => round(($invoice->total_taxes - $this->getTotalTaxPaid($invoice)) * $ratio, 2) * -1,
'status' => 'adjustment', 'status' => 'adjustment',
'taxable_amount' => round($calc->getNetSubtotal() * $ratio, 2) * -1, 'taxable_amount' => round($calc->getNetSubtotal() * $refund_ratio, 2) * -1,
'adjustment' => 0,
], ],
], ],
]); ]);
@ -278,10 +276,8 @@ class PaymentTransactionEventEntry implements ShouldQueue
'tax_details' => $details, 'tax_details' => $details,
'payment_history' => $this->payments->toArray(), 'payment_history' => $this->payments->toArray(),
'tax_summary' => [ 'tax_summary' => [
'total_taxes' => $invoice->total_taxes * -1, 'tax_amount' => round($invoice->total_taxes - $this->getTotalTaxPaid($invoice), 2) * -1,
'taxable_amount' => $calc->getNetSubtotal() * -1, 'taxable_amount' => $calc->getNetSubtotal() * -1,
'adjustment' => 0,
'tax_adjustment' => round($invoice->total_taxes - $this->getTotalTaxPaid($invoice), 2) * -1,
'status' => 'adjustment', 'status' => 'adjustment',
], ],
], ],

View File

@ -42,7 +42,7 @@ use App\DataMapper\TransactionEventMetadata;
* @property float $credit_balance * @property float $credit_balance
* @property float $credit_amount * @property float $credit_amount
* @property int|null $credit_status * @property int|null $credit_status
* @property Carbon|null $period * @property \Carbon\Carbon|null $period
* @method static \Illuminate\Database\Eloquent\Builder|StaticModel company() * @method static \Illuminate\Database\Eloquent\Builder|StaticModel company()
* @method static \Illuminate\Database\Eloquent\Builder|StaticModel exclude($columns) * @method static \Illuminate\Database\Eloquent\Builder|StaticModel exclude($columns)
* @mixin \Eloquent * @mixin \Eloquent

View File

@ -77,12 +77,12 @@ class InvoiceItemReportRow
$this->invoice->date, $this->invoice->date,
$this->tax_detail->tax_name, $this->tax_detail->tax_name,
$this->tax_detail->tax_rate, $this->tax_detail->tax_rate,
$this->tax_detail->tax_amount_adjustment, $this->tax_detail->tax_amount,
$this->tax_detail->taxable_amount_adjustment, $this->tax_detail->taxable_amount,
$this->status->label(), $this->status->label(),
]; ];
return $this->appendRegionalColumns($row, $this->tax_detail->tax_amount_adjustment); return $this->appendRegionalColumns($row, $this->tax_detail->tax_amount);
} }
/** /**

View File

@ -40,7 +40,7 @@ class InvoiceReportRow
ctrans('texts.invoice_date'), ctrans('texts.invoice_date'),
ctrans('texts.invoice_total'), ctrans('texts.invoice_total'),
ctrans('texts.paid'), ctrans('texts.paid'),
ctrans('texts.total_taxes'), ctrans('texts.tax_amount'),
ctrans('texts.taxable_amount'), ctrans('texts.taxable_amount'),
ctrans('texts.notes'), ctrans('texts.notes'),
]; ];
@ -60,14 +60,14 @@ class InvoiceReportRow
$this->row_data = [ $this->row_data = [
$this->invoice->number, $this->invoice->number,
$this->invoice->date, $this->invoice->date,
$this->invoice->amount, $this->event->invoice_amount,
$this->invoice->paid_to_date, $this->event->invoice_paid_to_date,
$this->tax_summary->total_taxes, $this->tax_summary->tax_amount,
$this->tax_summary->taxable_amount, $this->tax_summary->taxable_amount,
$this->tax_summary->status->label(), $this->tax_summary->status->label(),
]; ];
$this->appendRegionalColumns($this->tax_summary->total_taxes); $this->appendRegionalColumns($this->tax_summary->tax_amount);
return $this->row_data; return $this->row_data;
} }
@ -80,14 +80,14 @@ class InvoiceReportRow
$this->row_data = [ $this->row_data = [
$this->invoice->number, $this->invoice->number,
$this->invoice->date, $this->invoice->date,
$this->tax_summary->adjustment, $this->event->invoice_amount,
$this->event->metadata->tax_report->payment_history?->sum('amount') ?? 0, $this->event->metadata->tax_report->payment_history?->sum('amount') ?? 0,
$this->tax_summary->tax_adjustment, $this->tax_summary->tax_amount,
$this->tax_summary->taxable_amount, $this->tax_summary->taxable_amount,
$this->tax_summary->status->label(), $this->tax_summary->status->label(),
]; ];
$this->appendRegionalColumns($this->tax_summary->tax_adjustment); $this->appendRegionalColumns($this->tax_summary->tax_amount);
return $this->row_data; return $this->row_data;
} }
@ -100,14 +100,14 @@ class InvoiceReportRow
$this->row_data = [ $this->row_data = [
$this->invoice->number, $this->invoice->number,
$this->invoice->date, $this->invoice->date,
$this->invoice->amount, $this->event->invoice_amount,
$this->event->invoice_paid_to_date, $this->event->invoice_paid_to_date,
$this->tax_summary->total_taxes, $this->tax_summary->tax_amount,
$this->event->invoice_paid_to_date - $this->invoice->amount, // Negative adjustment amount $this->tax_summary->taxable_amount, // Negative adjustment amount
$this->tax_summary->status->label(), $this->tax_summary->status->label(),
]; ];
$this->appendRegionalColumns($this->tax_summary->tax_adjustment); $this->appendRegionalColumns($this->tax_summary->tax_amount);
return $this->row_data; return $this->row_data;
} }
@ -117,21 +117,17 @@ class InvoiceReportRow
*/ */
public function buildCancelledRow(): array public function buildCancelledRow(): array
{ {
$paid_ratio = $this->event->invoice_amount > 0
? $this->event->invoice_paid_to_date / $this->event->invoice_amount
: 0;
$this->row_data = [ $this->row_data = [
$this->invoice->number, $this->invoice->number,
$this->invoice->date, $this->invoice->date,
$this->event->invoice_paid_to_date, $this->event->invoice_paid_to_date,
$this->event->metadata->tax_report->payment_history?->sum('amount') ?? 0, $this->event->metadata->tax_report->payment_history?->sum('amount') ?? 0,
$paid_ratio * $this->tax_summary->total_taxes, $this->tax_summary->tax_amount,
$this->tax_summary->taxable_amount, $this->tax_summary->taxable_amount,
$this->tax_summary->status->label(), $this->tax_summary->status->label(),
]; ];
$this->appendRegionalColumns($paid_ratio * $this->tax_summary->total_taxes); $this->appendRegionalColumns($this->tax_summary->tax_amount);
return $this->row_data; return $this->row_data;
} }
@ -146,12 +142,12 @@ class InvoiceReportRow
$this->invoice->date, $this->invoice->date,
$this->invoice->amount * -1, $this->invoice->amount * -1,
($this->event->metadata->tax_report->payment_history?->sum('amount') ?? 0) * -1, ($this->event->metadata->tax_report->payment_history?->sum('amount') ?? 0) * -1,
$this->tax_summary->total_taxes * -1, $this->tax_summary->tax_amount,
$this->tax_summary->taxable_amount * -1, $this->tax_summary->taxable_amount,
$this->tax_summary->status->label(), $this->tax_summary->status->label(),
]; ];
$this->appendRegionalColumns($this->tax_summary->total_taxes * -1); $this->appendRegionalColumns($this->tax_summary->tax_amount);
return $this->row_data; return $this->row_data;
} }

View File

@ -1,213 +0,0 @@
# Tax Period Report - Refactoring Summary
## Overview
The TaxPeriodReport has been comprehensively refactored to provide a clean, extensible architecture for generating tax compliance reports across different regions and accounting methods.
## What Was Accomplished
### ✅ **Complete Architectural Refactor**
**Before**: 761 lines, mixed concerns, duplicated code
**After**: 435 lines + 9 specialized classes
### ✅ **New Architecture Components**
#### **Core DTOs** (`app/Services/Report/TaxPeriod/`)
- `TaxReportStatus.php` - Enum for status values (updated, delta, adjustment, cancelled, deleted)
- `TaxSummary.php` - Tax summary data with calculated helpers
- `TaxDetail.php` - Tax detail line items with calculated helpers
#### **Regional Tax Support**
- `RegionalTaxCalculator.php` - Interface for region-specific implementations
- `UsaTaxCalculator.php` - USA state/county/city/district tax breakdown
- `GenericTaxCalculator.php` - Fallback for other regions
- `RegionalTaxCalculatorFactory.php` - Auto-selects appropriate calculator
#### **Report Builders**
- `InvoiceReportRow.php` - Builds invoice-level rows (handles all status types)
- `InvoiceItemReportRow.php` - Builds tax detail rows
### ✅ **Metadata Optimization**
Removed redundant/unused fields:
**TaxSummary**:
- ❌ Removed `total_paid` (now calculated on-demand)
**TaxDetail**:
- ❌ Removed `tax_amount_paid` (calculated)
- ❌ Removed `tax_amount_paid_adjustment` (always 0)
- ❌ Removed `tax_amount_remaining_adjustment` (always 0)
**Storage Reduction**: ~36% per transaction event
### ✅ **Test Infrastructure**
Added `skip_initialization` parameter to TaxPeriodReport constructor for clean test isolation:
```php
new TaxPeriodReport($company, $payload, skip_initialization: true)
```
This prevents the prophylactic `initializeData()` from polluting test data.
## Test Status
### **Passing Tests (7/15)**
✅ testSingleInvoiceTaxReportStructure
✅ testInvoiceReportingOverMultiplePeriodsWithAccrualAccountingCheckAdjustmentsForIncreases
✅ testInvoiceReportingOverMultiplePeriodsWithAccrualAccountingCheckAdjustmentsForDecreases
✅ testInvoiceReportingOverMultiplePeriodsWithCashAccountingCheckAdjustments
✅ testInvoiceWithRefundAndCashReportsAreCorrect
✅ testInvoiceWithRefundAndCashReportsAreCorrectAcrossReportingPeriods
✅ testCancelledInvoiceInSamePeriodAccrual
### **Pending Tests (8/15)** - Require Listener Fix
The following tests are scaffolded but require a fix to `InvoiceTransactionEventEntry`:
⏸️ testCancelledInvoiceInNextPeriodAccrual
⏸️ testCancelledInvoiceWithPartialPaymentAccrual
⏸️ testDeletedInvoiceInSamePeriodAccrual
⏸️ testDeletedInvoiceInNextPeriodAccrual
⏸️ testDeletedPaidInvoiceInNextPeriodAccrual
⏸️ testPaymentDeletedInSamePeriodCash
⏸️ testPaymentDeletedInNextPeriodCash
⏸️ testPaymentDeletedInNextPeriodAccrual
## Known Issue: InvoiceTransactionEventEntry Listener
**Location**: `app/Listeners/Invoice/InvoiceTransactionEventEntry.php:68-70`
**Problem**: The listener returns early when invoice amount hasn't changed, even if the STATUS has changed:
```php
else if(BcMath::comp($invoice->amount, $event->invoice_amount) == 0){
return; // ❌ BUG: Returns even if status changed!
}
```
**Impact**: When an invoice is cancelled or has its status changed without amount changes, no new transaction event is created for that period.
**Fix Needed**: Add status change detection:
```php
else if(BcMath::comp($invoice->amount, $event->invoice_amount) == 0 &&
$invoice->status_id == $event->invoice_status){
return; // ✅ Only return if BOTH amount AND status unchanged
}
```
**Workaround**: Tests can explicitly pass the `$force_period` parameter to bypass the early return logic.
## Benefits of Refactoring
### 1. **Regional Extensibility**
Adding support for a new region (Canada, EU, Australia, etc.) is now trivial:
```php
class CanadaTaxCalculator implements RegionalTaxCalculator {
public function getHeaders(): array {
return ['Province', 'GST', 'PST', 'HST'];
}
public function calculateColumns(Invoice $invoice, float $amount): array {
// Canadian-specific logic
}
public static function supports(string $country_iso): bool {
return $country_iso === 'CA';
}
}
```
Register in `RegionalTaxCalculatorFactory::CALCULATORS` and you're done!
### 2. **Type Safety**
- Enums instead of magic strings
- DTOs with typed properties
- Clear method signatures
### 3. **Maintainability**
- Single Responsibility Principle
- DRY - no duplicated USA tax calculation code
- Clear separation: orchestration vs presentation vs calculation
### 4. **Performance**
- 36% reduction in metadata storage
- Calculations only when needed (lazy)
- Cleaner, faster queries
### 5. **Testability**
- Each component can be unit tested independently
- Clean test isolation with `skip_initialization` flag
- Row builders use dependency injection
## Usage Examples
### Basic Report Generation
```php
$payload = [
'start_date' => '2025-10-01',
'end_date' => '2025-10-31',
'date_range' => 'custom',
'is_income_billed' => true, // true = accrual, false = cash
];
$report = new TaxPeriodReport($company, $payload);
$xlsx_content = $report->run();
```
### Testing with Isolation
```php
$report = new TaxPeriodReport($company, $payload, skip_initialization: true);
$data = $report->boot()->getData();
// Now you have clean test data without interference from other tests
```
### Using Calculated Methods
```php
$tax_summary = TaxSummary::fromMetadata($event->metadata->tax_report->tax_summary);
// Calculate on-demand
$paid = $tax_summary->calculateTotalPaid($invoice->amount, $invoice->paid_to_date);
$remaining = $tax_summary->calculateTotalRemaining($invoice->amount, $invoice->paid_to_date);
$ratio = $tax_summary->getPaymentRatio($invoice->amount, $invoice->paid_to_date);
$tax_detail = TaxDetail::fromMetadata($event->metadata->tax_report->tax_details[0]);
$tax_paid = $tax_detail->calculateTaxPaid($ratio);
```
## Migration Notes
### No Breaking Changes
The refactored code is **100% backward compatible** with existing metadata structures. Old metadata with redundant fields will simply ignore them when creating DTOs.
### Production Deployment
No migration required. The refactored report works with existing transaction events and metadata.
### Future Optimization
Once InvoiceTransactionEventEntry is updated to use the streamlined metadata structure (removing redundant fields), storage savings will be realized automatically.
## Files Modified
- `app/Services/Report/TaxPeriodReport.php` - Main orchestration class
- Created 9 new support classes in `app/Services/Report/TaxPeriod/`
- `tests/Feature/Export/TaxPeriodReportTest.php` - Added 8 new test scenarios
## Next Steps
1. **Fix InvoiceTransactionEventEntry listener** to detect status changes
2. **Enable pending tests** once listener is fixed
3. **Add more regions** as needed (Canada, EU, etc.)
4. **Update listeners** to use optimized metadata structure (optional)
5. **Add summary sheet calculations** (currently empty placeholder)
## Questions?
This refactoring maintains all existing functionality while providing a solid foundation for future enhancements. All core business logic remains unchanged - this was purely a structural improvement.

View File

@ -22,8 +22,8 @@ class TaxDetail
public float $tax_rate, public float $tax_rate,
public float $taxable_amount, public float $taxable_amount,
public float $tax_amount, public float $tax_amount,
public float $tax_amount_adjustment = 0, public float $line_total = 0,
public float $taxable_amount_adjustment = 0, public float $total_tax = 0,
) {} ) {}
/** /**
@ -36,8 +36,8 @@ class TaxDetail
tax_rate: $metadata->tax_rate, tax_rate: $metadata->tax_rate,
taxable_amount: $metadata->taxable_amount ?? 0, taxable_amount: $metadata->taxable_amount ?? 0,
tax_amount: $metadata->tax_amount ?? 0, tax_amount: $metadata->tax_amount ?? 0,
tax_amount_adjustment: $metadata->tax_amount_adjustment ?? 0, line_total: $metadata->line_total ?? 0,
taxable_amount_adjustment: $metadata->taxable_amount_adjustment ?? 0, total_tax: $metadata->total_tax ?? 0,
); );
} }
@ -85,13 +85,6 @@ class TaxDetail
return round($this->taxable_amount * (1 - $payment_ratio), 2); return round($this->taxable_amount * (1 - $payment_ratio), 2);
} }
/**
* Check if this detail represents an adjustment (delta/refund)
*/
public function isAdjustment(): bool
{
return $this->tax_amount_adjustment != 0 || $this->taxable_amount_adjustment != 0;
}
/** /**
* Get effective tax rate as percentage string (e.g., "10%") * Get effective tax rate as percentage string (e.g., "10%")
@ -131,8 +124,8 @@ class TaxDetail
'tax_rate' => $this->tax_rate, 'tax_rate' => $this->tax_rate,
'taxable_amount' => $this->taxable_amount, 'taxable_amount' => $this->taxable_amount,
'tax_amount' => $this->tax_amount, 'tax_amount' => $this->tax_amount,
'tax_amount_adjustment' => $this->tax_amount_adjustment, 'line_total' => $this->line_total,
'taxable_amount_adjustment' => $this->taxable_amount_adjustment, 'total_tax' => $this->total_tax,
]; ];
} }
} }

View File

@ -14,60 +14,43 @@ namespace App\Services\Report\TaxPeriod;
/** /**
* Data Transfer Object for tax summary information * Data Transfer Object for tax summary information
*
* Represents the taxable amount and tax amount for an invoice in a specific period.
* The meaning of these values depends on the status:
*
* - 'updated': Full invoice tax liability (accrual) or paid tax (cash)
* - 'delta': Differential tax change from invoice updates
* - 'adjustment': Tax change from payment refunds/deletions
* - 'cancelled': Proportional tax on refunded/cancelled amount
* - 'deleted': Full tax reversal
* - 'reversed': Full tax reversal of credit note
*/ */
class TaxSummary class TaxSummary
{ {
public function __construct( public function __construct(
public float $taxable_amount, public float $taxable_amount,
public float $total_taxes, public float $tax_amount,
public TaxReportStatus $status, public TaxReportStatus $status,
public float $adjustment = 0,
public float $tax_adjustment = 0,
) { ) {
} }
/** /**
* Create from transaction event metadata * Create from transaction event metadata
*/ */
public static function fromMetadata(object $metadata): self public static function fromMetadata($metadata): self
{ {
// Handle both object and array access
$taxable_amount = is_array($metadata) ? ($metadata['taxable_amount'] ?? 0) : ($metadata->taxable_amount ?? 0);
$tax_amount = is_array($metadata) ? ($metadata['tax_amount'] ?? $metadata['tax_adjustment'] ?? 0) : ($metadata->tax_amount ?? $metadata->tax_adjustment ?? 0);
$status = is_array($metadata) ? ($metadata['status'] ?? 'updated') : ($metadata->status ?? 'updated');
return new self( return new self(
taxable_amount: $metadata->taxable_amount ?? 0, taxable_amount: $taxable_amount,
total_taxes: $metadata->total_taxes ?? 0, tax_amount: $tax_amount,
status: TaxReportStatus::from($metadata->status ?? 'updated'), status: TaxReportStatus::from($status),
adjustment: $metadata->adjustment ?? 0,
tax_adjustment: $metadata->tax_adjustment ?? 0,
); );
} }
/**
* Calculate total tax paid based on invoice payment ratio
*
* @param float $invoice_amount Total invoice amount
* @param float $invoice_paid_to_date Amount paid on invoice
* @return float Tax amount that has been paid
*/
public function calculateTotalPaid(float $invoice_amount, float $invoice_paid_to_date): float
{
if ($invoice_amount == 0) {
return 0;
}
return round($this->total_taxes * ($invoice_paid_to_date / $invoice_amount), 2);
}
/**
* Calculate total tax remaining
*
* @param float $invoice_amount Total invoice amount
* @param float $invoice_paid_to_date Amount paid on invoice
* @return float Tax amount still outstanding
*/
public function calculateTotalRemaining(float $invoice_amount, float $invoice_paid_to_date): float
{
return round($this->total_taxes - $this->calculateTotalPaid($invoice_amount, $invoice_paid_to_date), 2);
}
/** /**
* Get the payment ratio for this invoice * Get the payment ratio for this invoice
* *
@ -87,10 +70,8 @@ class TaxSummary
{ {
return [ return [
'taxable_amount' => $this->taxable_amount, 'taxable_amount' => $this->taxable_amount,
'total_taxes' => $this->total_taxes, 'tax_amount' => $this->tax_amount,
'status' => $this->status->value, 'status' => $this->status->value,
'adjustment' => $this->adjustment,
'tax_adjustment' => $this->tax_adjustment,
]; ];
} }
} }

View File

@ -71,7 +71,7 @@ class TaxPeriodReport extends BaseExport
public function run() public function run()
{ {
nlog($this->input); // nlog($this->input);
MultiDB::setDb($this->company->db); MultiDB::setDb($this->company->db);
App::forgetInstance('translator'); App::forgetInstance('translator');
App::setLocale($this->company->locale()); App::setLocale($this->company->locale());
@ -120,8 +120,6 @@ class TaxPeriodReport extends BaseExport
{ {
$this->cash_accounting = $this->input['is_income_billed'] ? false : true; $this->cash_accounting = $this->input['is_income_billed'] ? false : true;
nlog("IS CASH ACCOUNTING ? => {$this->cash_accounting}");
return $this; return $this;
} }
@ -135,16 +133,21 @@ class TaxPeriodReport extends BaseExport
*/ */
private function initializeData(): self private function initializeData(): self
{ {
$q = Invoice::withTrashed() $q = Invoice::withTrashed()
->where('company_id', $this->company->id) ->where('company_id', $this->company->id)
->whereIn('status_id', [2,3,4,5,6]) ->whereIn('status_id', [2,3,4,5,6])
->whereBetween('date', ['1970-01-01', now()->subMonth()->endOfMonth()->format('Y-m-d')]) ->whereBetween('date', ['1970-01-01', $this->end_date])
->whereDoesntHave('transaction_events'); // ->whereDoesntHave('transaction_events'); //filter by no transaction events for THIS month.
->whereDoesntHave('transaction_events', function ($query) {
$query->where('period', $this->end_date);
});
$q->cursor() $q->cursor()
->each(function ($invoice) { ->each(function ($invoice) {
(new InvoiceTransactionEventEntry())->run($invoice, \Carbon\Carbon::parse($invoice->date)->endOfMonth()->format('Y-m-d')); (new InvoiceTransactionEventEntry())->run($invoice, $this->end_date);
if (in_array($invoice->status_id, [Invoice::STATUS_PAID, Invoice::STATUS_PARTIAL])) { if (in_array($invoice->status_id, [Invoice::STATUS_PAID, Invoice::STATUS_PARTIAL])) {
@ -177,13 +180,21 @@ class TaxPeriodReport extends BaseExport
->whereIn('metadata->tax_report->tax_summary->status', ['cancelled', 'deleted']); ->whereIn('metadata->tax_report->tax_summary->status', ['cancelled', 'deleted']);
}); });
nlog("end date = {$this->end_date} =>" . $ii->count());
$ii->cursor() $ii->cursor()
->each(function ($invoice) { ->each(function ($invoice) {
// Iterate through each month between start_date and end_date
// $current_date = Carbon::parse($this->start_date);
// $end_date_carbon = Carbon::parse($this->end_date);
// while ($current_date->lte($end_date_carbon)) {
// $last_day_of_month = $current_date->copy()->endOfMonth()->format('Y-m-d');
// (new InvoiceTransactionEventEntry())->run($invoice, $last_day_of_month);
// $current_date->addMonth();
// }
(new InvoiceTransactionEventEntry())->run($invoice, $this->end_date); (new InvoiceTransactionEventEntry())->run($invoice, $this->end_date);
}); });
return $this; return $this;
@ -385,8 +396,6 @@ class TaxPeriodReport extends BaseExport
$query = $this->resolveQuery(); $query = $this->resolveQuery();
nlog($query->count(). " records to iterate");
// Initialize with headers // Initialize with headers
$this->data['invoices'] = [InvoiceReportRow::getHeaders($this->regional_calculator)]; $this->data['invoices'] = [InvoiceReportRow::getHeaders($this->regional_calculator)];
$this->data['invoice_items'] = [InvoiceItemReportRow::getHeaders($this->regional_calculator)]; $this->data['invoice_items'] = [InvoiceItemReportRow::getHeaders($this->regional_calculator)];
@ -427,7 +436,6 @@ class TaxPeriodReport extends BaseExport
{ {
$tax_summary = TaxSummary::fromMetadata($event->metadata->tax_report->tax_summary); $tax_summary = TaxSummary::fromMetadata($event->metadata->tax_report->tax_summary);
nlog($event->metadata->toArray());
// Build and add invoice row // Build and add invoice row
$invoice_row_builder = new InvoiceReportRow( $invoice_row_builder = new InvoiceReportRow(
$invoice, $invoice,

File diff suppressed because it is too large Load Diff