Padding tax reports

This commit is contained in:
David Bomba 2025-08-05 13:46:55 +10:00
parent 3b9962fb22
commit 33c5b90dce
8 changed files with 288 additions and 51 deletions

View File

@ -20,12 +20,13 @@ class TaxSummary
public float $total_taxes; // Tax collected and confirmed (ie. Invoice Paid) public float $total_taxes; // Tax collected and confirmed (ie. Invoice Paid)
public float $total_paid; // Tax pending collection (Outstanding tax of balance owing) public float $total_paid; // Tax pending collection (Outstanding tax of balance owing)
public string $status; public string $status;
public float $adjustment;
public function __construct(array $attributes = []) public function __construct(array $attributes = [])
{ {
$this->total_taxes = $attributes['total_taxes'] ?? 0.0; $this->total_taxes = $attributes['total_taxes'] ?? 0.0;
$this->total_paid = $attributes['total_paid'] ?? 0.0; $this->total_paid = $attributes['total_paid'] ?? 0.0;
$this->status = $attributes['status'] ?? 'updated'; $this->status = $attributes['status'] ?? 'updated';
$this->adjustment = $attributes['adjustment'] ?? 0.0;
} }
public function toArray(): array public function toArray(): array
@ -34,6 +35,7 @@ class TaxSummary
'total_taxes' => $this->total_taxes, 'total_taxes' => $this->total_taxes,
'total_paid' => $this->total_paid, 'total_paid' => $this->total_paid,
'status' => $this->status, 'status' => $this->status,
'adjustment' => $this->adjustment,
]; ];
} }
} }

View File

@ -67,6 +67,7 @@ class InvoiceTransactionEventEntry
'event_id' => $invoice->is_deleted ? TransactionEvent::INVOICE_DELETED : TransactionEvent::INVOICE_UPDATED, 'event_id' => $invoice->is_deleted ? TransactionEvent::INVOICE_DELETED : TransactionEvent::INVOICE_UPDATED,
'timestamp' => now()->timestamp, 'timestamp' => now()->timestamp,
'metadata' => $this->getMetadata($invoice), 'metadata' => $this->getMetadata($invoice),
'period' => now()->endOfMonth()->format('Y-m-d'),
]); ]);
} }

View File

@ -0,0 +1,234 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2025. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Listeners\Payment;
use App\Models\Invoice;
use App\Models\Activity;
use App\Models\TransactionEvent;
use Illuminate\Support\Collection;
use App\DataMapper\TaxReport\TaxDetail;
use App\DataMapper\TaxReport\TaxReport;
use App\DataMapper\TaxReport\TaxSummary;
use App\Repositories\ActivityRepository;
use Illuminate\Contracts\Queue\ShouldQueue;
use App\DataMapper\TransactionEventMetadata;
use App\Libraries\MultiDB;
use App\Models\Payment;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Carbon\Carbon;
class PaymentTransactionEventEntry implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public $tries = 1;
private float $paid_ratio;
private Collection $payments;
/**
*/
public function __construct(private Payment $payment, private array $invoice_ids, private string $db)
{}
public function handle()
{
nlog("PaymentTransactionEventEntry::handle");
//payment vs refunded
MultiDB::setDb($this->db);
$this->payments = $this->payment
->invoices()
->get()
->filter(function($invoice){
//only insert adjustment entries if we are after the end of the month!!
return Carbon::parse($invoice->date)->endOfMonth()->isBefore(now()->addSeconds($this->payment->company->timezone_offset()));
})
->map(function ($invoice) {
return [
'number' => $this->payment->number,
'amount' => $invoice->pivot->amount,
'refunded' => $invoice->pivot->refunded,
'date' => $invoice->pivot->created_at->format('Y-m-d'),
];
});
Invoice::withTrashed()
->whereIn('id', $this->invoice_ids)
->get()
->filter(function($invoice){
//only insert adjustment entries if we are after the end of the month!!
return Carbon::parse($invoice->date)->endOfMonth()->isBefore(now()->addSeconds($this->payment->company->timezone_offset()));
})
->each(function($invoice){
$this->setPaidRatio($invoice);
TransactionEvent::create([
'invoice_id' => $invoice->id,
'client_id' => $invoice->client_id,
'client_balance' => $invoice->client->balance,
'client_paid_to_date' => $invoice->client->paid_to_date,
'client_credit_balance' => $invoice->client->credit_balance,
'invoice_balance' => $invoice->balance ?? 0,
'invoice_amount' => $invoice->amount ?? 0 ,
'invoice_partial' => $invoice->partial ?? 0,
'invoice_paid_to_date' => $invoice->paid_to_date ?? 0,
'invoice_status' => $invoice->is_deleted ? 7 : $invoice->status_id,
'event_id' => $this->payment->is_deleted ? TransactionEvent::PAYMENT_DELETED : TransactionEvent::PAYMENT_REFUNDED,
'timestamp' => now()->timestamp,
'metadata' => $this->getMetadata($invoice),
'period' => now()->endOfMonth()->format('Y-m-d'),
'payment_id' => $this->payment->id,
'payment_amount' => $this->payment->amount,
'payment_refunded' => $this->payment->refunded,
'payment_applied' => $this->payment->applied,
'payment_status' => $this->payment->status_id,
]);
});
}
private function setPaidRatio(Invoice $invoice): self
{
if ($invoice->amount == 0) {
$this->paid_ratio = 0;
return $this;
}
$this->paid_ratio = $invoice->paid_to_date / $invoice->amount;
return $this;
}
private function calculateRatio(float $amount): float
{
return round($amount * $this->paid_ratio, 2);
}
/**
* Existing tax details are not deleted, but pending taxes are set to 0
*
* @param mixed $invoice
*/
private function getRefundedMetaData($invoice)
{
$calc = $invoice->calc();
$details = [];
$taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray());
foreach ($taxes as $tax) {
$tax_detail = [
'tax_name' => $tax['name'],
'tax_rate' => $tax['tax_rate'],
'taxable_amount' => $tax['base_amount'] ?? $calc->getNetSubtotal(),
'tax_amount' => $tax['total'],
'tax_amount_paid' => $this->calculateRatio($tax['total']),
'tax_amount_remaining' => round($tax['total'] - $this->calculateRatio($tax['total']), 2),
];
$details[] = $tax_detail;
}
return new TransactionEventMetadata([
'tax_report' => [
'tax_details' => $details,
'payment_history' => $this->payments->toArray(),
'tax_summary' => [
'total_taxes' => $invoice->total_taxes,
'total_paid' => $this->getTotalTaxPaid($invoice),
'adjustment' => round($invoice->total_taxes - $this->getTotalTaxPaid($invoice), 2) * -1,
'status' => 'adjustment',
],
],
]);
}
/**
* Set all tax details to 0
*
* @param mixed $invoice
*/
private function getDeletedMetaData($invoice)
{
$calc = $invoice->calc();
$details = [];
$taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray());
foreach ($taxes as $tax) {
$tax_detail = [
'tax_name' => $tax['name'],
'tax_rate' => $tax['tax_rate'],
'taxable_amount' => $tax['base_amount'] ?? $calc->getNetSubtotal(),
'tax_amount' => $tax['total'],
'tax_amount_paid' => $this->calculateRatio($tax['total']),
'tax_amount_remaining' => 0,
];
$details[] = $tax_detail;
}
return new TransactionEventMetadata([
'tax_report' => [
'tax_details' => $details,
'payment_history' => $this->payments->toArray(),
'tax_summary' => [
'total_taxes' => $invoice->total_taxes,
'total_paid' => $this->getTotalTaxPaid($invoice),
'adjustment' => round($invoice->total_taxes - $this->getTotalTaxPaid($invoice), 2) * -1,
'status' => 'adjustment',
],
],
]);
}
private function getMetadata($invoice)
{
if ($this->payment->is_deleted) {
return $this->getDeletedMetaData($invoice);
} else {
return $this->getRefundedMetaData($invoice);
}
}
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);
}
public function middleware()
{
return [(new WithoutOverlapping("payment_transaction_event_entry_".$this->payment->id.'_'.$this->db))->releaseAfter(10)->expireAfter(30)];
}
}

View File

@ -42,6 +42,7 @@ use App\DataMapper\TransactionEventMetadata;
* @property string $credit_balance * @property string $credit_balance
* @property string $credit_amount * @property string $credit_amount
* @property int|null $credit_status * @property int|null $credit_status
* @property 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
@ -56,11 +57,12 @@ class TransactionEvent extends StaticModel
'metadata' => TransactionEventMetadata::class, 'metadata' => TransactionEventMetadata::class,
'payment_request' => 'array', 'payment_request' => 'array',
'paymentables' => 'array', 'paymentables' => 'array',
'period' => 'date',
]; ];
public const INVOICE_UPDATED = 1; public const INVOICE_UPDATED = 1;
public const INVOICE_DELETED = 2; public const PAYMENT_REFUNDED = 2;
public const PAYMENT_DELETED = 3; public const PAYMENT_DELETED = 3;
} }

View File

@ -12,10 +12,11 @@
namespace App\Services\Payment; namespace App\Services\Payment;
use App\Models\BankTransaction;
use App\Models\Credit; use App\Models\Credit;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Payment; use App\Models\Payment;
use App\Models\BankTransaction;
use App\Listeners\Payment\PaymentTransactionEventEntry;
use Illuminate\Contracts\Container\BindingResolutionException; use Illuminate\Contracts\Container\BindingResolutionException;
class DeletePayment class DeletePayment
@ -87,6 +88,9 @@ class DeletePayment
$this->_paid_to_date_deleted = 0; $this->_paid_to_date_deleted = 0;
if ($this->payment->invoices()->exists()) { if ($this->payment->invoices()->exists()) {
$invoice_ids = $this->payment->invoices()->pluck('id');
$this->payment->invoices()->each(function ($paymentable_invoice) { $this->payment->invoices()->each(function ($paymentable_invoice) {
$net_deletable = $paymentable_invoice->pivot->amount - $paymentable_invoice->pivot->refunded; $net_deletable = $paymentable_invoice->pivot->amount - $paymentable_invoice->pivot->refunded;
@ -159,6 +163,8 @@ class DeletePayment
}); });
PaymentTransactionEventEntry::dispatch($this->payment, $invoice_ids, $this->payment->company->db);
} }
//sometimes the payment is NOT created properly, this catches the payment and prevents the paid to date reducing inappropriately. //sometimes the payment is NOT created properly, this catches the payment and prevents the paid to date reducing inappropriately.

View File

@ -12,15 +12,16 @@
namespace App\Services\Payment; namespace App\Services\Payment;
use App\Exceptions\PaymentRefundFailed; use stdClass;
use App\Jobs\Payment\EmailRefundPayment; use App\Utils\Ninja;
use App\Models\Activity;
use App\Models\Credit; use App\Models\Credit;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\Payment; use App\Models\Payment;
use App\Models\Activity;
use App\Exceptions\PaymentRefundFailed;
use App\Jobs\Payment\EmailRefundPayment;
use App\Repositories\ActivityRepository; use App\Repositories\ActivityRepository;
use App\Utils\Ninja; use App\Listeners\Payment\PaymentTransactionEventEntry;
use stdClass;
class RefundPayment class RefundPayment
{ {
@ -312,6 +313,8 @@ class RefundPayment
} }
PaymentTransactionEventEntry::dispatch($this->payment, array_column($this->refund_data['invoices'], 'invoice_id'), $this->payment->company->db);
} else { } 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. //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.

View File

@ -11,6 +11,7 @@ use App\Services\Report\TaxSummaryReport;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
use App\Models\Invoice; use App\Models\Invoice;
use App\Listeners\Invoice\InvoiceTransactionEventEntry;
class TaxReport class TaxReport
{ {
@ -189,53 +190,16 @@ class TaxReport
/** @var Invoice $invoice */ /** @var Invoice $invoice */
foreach($this->query->cursor() as $invoice){ foreach($this->query->cursor() as $invoice){
$calc = $invoice->calc(); if($invoice->transaction_events->count() == 0){
//Combine the line taxes with invoice taxes here to get a total tax amount (new InvoiceTransactionEventEntry())->run($invoice);
$taxes = array_merge($calc->getTaxMap()->merge($calc->getTotalTaxMap())->toArray());
$payment_amount = 0;
foreach ($invoice->payments()->get() as $payment) {
if($payment->pivot->created_at->addSeconds($offset)->isBetween($start_date_instance,$end_date_instance)){
$payment_amount += ($payment->pivot->amount - $payment->pivot->refunded);
}
} }
$payment_amount = round($payment_amount,2); //get the invoice state as at the end of the current period.
$invoice_amount = round($invoice->amount,2); $invoice->transaction_events->each(function($event){
$total_taxes = round($invoice->total_taxes,2);
$pro_rata_payment_ratio = $payment_amount != 0 ? ($payment_amount/$invoice_amount) : 0; });
$total_taxes = $payment_amount != 0 ? ($payment_amount/$invoice_amount) * $invoice->total_taxes : 0;
$taxable_amount = $calc->getNetSubtotal();
$this->data['invoices'][] = [ //anything period the reporting period is considered an ADJUSTMENT
$invoice->number,
$invoice->date,
$invoice_amount,
$payment_amount,
$total_taxes,
$taxable_amount,
];
foreach($taxes as $tax){
$this->data['invoice_items'][] = [
$invoice->number,
$invoice->date,
$invoice_amount,
$payment_amount,
$tax['name'],
$tax['tax_rate'],
$tax['total'],
$tax['total'] * $pro_rata_payment_ratio,
$tax['base_amount'] ?? $calc->getNetSubtotal(),
$tax['nexus'] ?? '',
];
}
} }

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('transaction_events', function (Blueprint $table) {
$table->date('period')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
}
};