Improvements for delete payments

This commit is contained in:
David Bomba 2025-09-08 06:59:56 +10:00
parent 3b63cd4cb3
commit cde3ca8af5
11 changed files with 307 additions and 17 deletions

View File

@ -16,11 +16,16 @@ use App\Libraries\MultiDB;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
class PaymentBalanceActivity implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public $tries = 1;

View File

@ -46,7 +46,7 @@ class PaymentTransactionEventEntry implements ShouldQueue
/**
*/
public function __construct(private Payment $payment, private array $invoice_ids, private string $db, private float $invoice_adjustment = 0, private bool $is_deleted = false)
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()

View File

@ -38,7 +38,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $description
* @property string|null $participant
* @property string|null $participant_name
* @property string $invoice_ids
* @property string|null $invoice_ids
* @property string|null $expense_id
* @property int|null $vendor_id
* @property int $status_id

View File

@ -96,21 +96,18 @@ class BTCPayPaymentDriver extends BaseDriver
/** @var \stdClass $btcpayRep */
$btcpayRep = json_decode($webhook_payload);
if ($btcpayRep == null) {
throw new PaymentFailed('Empty data');
}
if (empty($btcpayRep->invoiceId)) {
throw new PaymentFailed(
'Invalid BTCPayServer payment notification- did not receive invoice ID.'
);
return response()->json(['error' => 'Invalid BTCPayServer payment notification - did not receive invoice ID.'], 400);
}
if (!isset($btcpayRep->metadata->InvoiceNinjaPaymentHash)) {
throw new PaymentFailed(
'Invalid BTCPayServer payment notification- did not receive Payment Hashed ID.'
);
return response()->json(['error' => 'Invalid BTCPayServer payment notification - did not receive Payment Hashed ID.'], 400);
}
@ -138,9 +135,7 @@ class BTCPayPaymentDriver extends BaseDriver
$webhookClient = new Webhook($this->btcpay_url, $this->api_key);
if (!$webhookClient->isIncomingWebhookRequestValid($webhook_payload, $sig, $this->webhook_secret)) {
throw new \RuntimeException(
'Invalid BTCPayServer payment notification message received - signature did not match.'
);
return response()->json(['error' => 'Invalid BTCPayServer payment notification message received - signature did not match.'], 400);
}
$this->setPaymentMethod(GatewayType::CRYPTO);

View File

@ -92,7 +92,7 @@ class ClientService
return $this;
}
public function updateBalanceAndPaidToDate(float $balance, float $paid_to_date)
public function updateBalanceAndPaidToDate($balance, $paid_to_date)
{
DB::connection(config('database.default'))->transaction(function () use ($balance, $paid_to_date) {
@ -105,7 +105,7 @@ class ClientService
return $this;
}
public function updatePaidToDate(float $amount)
public function updatePaidToDate($amount)
{
DB::connection(config('database.default'))->transaction(function () use ($amount) {
@ -138,7 +138,7 @@ class ClientService
}
public function adjustCreditBalance(float $amount)
public function adjustCreditBalance(mixed $amount)
{
$this->client->credit_balance += $amount;

View File

@ -19,6 +19,11 @@ use App\Models\BankTransaction;
use App\Listeners\Payment\PaymentTransactionEventEntry;
use Illuminate\Contracts\Container\BindingResolutionException;
/**
*
* @deprecated in favour of DeletePaymentV2
*
*/
class DeletePayment
{
private float $_paid_to_date_deleted = 0;

View File

@ -0,0 +1,278 @@
<?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\Services\Payment;
use App\Models\Credit;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\BankTransaction;
use App\Listeners\Payment\PaymentTransactionEventEntry;
use App\Utils\BcMath;
use Illuminate\Contracts\Container\BindingResolutionException;
class DeletePaymentV2
{
private string $_paid_to_date_deleted = '0';
private string $total_payment_amount = '0';
/**
* @param Payment $payment
* @return void
*/
public function __construct(public Payment $payment, private bool $update_client_paid_to_date)
{
}
/**
* @return Payment
* @throws BindingResolutionException
*/
public function run()
{
\DB::connection(config('database.default'))->transaction(function () {
$this->payment = Payment::withTrashed()->where('id', $this->payment->id)->lockForUpdate()->first();
if ($this->payment && !$this->payment->is_deleted) {
$this->setStatus(Payment::STATUS_CANCELLED) //sets status of payment
->updateCreditables() //return the credits first
->adjustInvoices()
->deletePaymentables()
->cleanupPayment()
->save();
}
}, 2);
return $this->payment;
}
private function cleanupPayment()
{
$this->payment->is_deleted = true;
$this->payment->delete();
BankTransaction::query()->where('payment_id', $this->payment->id)->cursor()->each(function ($bt) {
$bt->invoice_ids = null;
$bt->payment_id = null;
$bt->status_id = 1;
$bt->save();
});
return $this;
}
/**
* Saves the payment.
*
* @return Payment $payment
*/
private function save()
{
$this->payment->save();
return $this->payment;
}
private function setStatus($status): self
{
$this->payment->status_id = $status;
return $this;
}
/**
* Iterates through the credit pivot records and updates the balance and paid to date.
*
* @return self
*/
private function updateCreditables(): self
{
if ($this->payment->credits()->exists()) {
$this->payment->credits()->where('is_deleted', 0)->each(function ($paymentable_credit) {
$multiplier = 1;
//balance remaining on the credit that can offset the paid to date.
$net_credit_amount = BcMath::sub($paymentable_credit->pivot->amount, $paymentable_credit->pivot->refunded,2);
//Updates the Global Total Payment Amount that can later be used to adjust the paid to date.
$this->total_payment_amount = BcMath::add($this->total_payment_amount, $net_credit_amount,2);
//Negative payments need cannot be "subtracted" from the paid to date. so the operator needs to be reversed.
if (BcMath::lessThan($net_credit_amount, 0, 2)) {
$multiplier = -1;
}
//Reverses the operator for the balance and paid to date this allows the amount to be "subtracted" from the paid to date.
$balance_multiplier = BcMath::mul($multiplier, -1, 0);
$balance_net_credit_amount = BcMath::mul($net_credit_amount, $balance_multiplier, 2);
$paymentable_credit->service()
->updateBalance($balance_net_credit_amount)
->updatePaidToDate($balance_net_credit_amount)
->setStatus(Credit::STATUS_SENT)
->save();
$client = $this->payment->client->fresh();
$client->service()
->adjustCreditBalance($net_credit_amount)
->save();
});
}
return $this;
}
/**
* Iterates through the invoice pivot records and updates the balance and paid to date.
*
* @return self
*/
private function adjustInvoices(): self
{
if ($this->payment->invoices()->exists()) {
//Updates the Global Total Payment Amount that can later be used to adjust the paid to date.
$this->total_payment_amount = BcMath::add($this->total_payment_amount, BcMath::sub($this->payment->amount,$this->payment->refunded,2),2);
$this->payment->invoices()->each(function ($paymentable_invoice) {
$net_deletable = BcMath::sub($paymentable_invoice->pivot->amount, $paymentable_invoice->pivot->refunded, 2);
$this->_paid_to_date_deleted = BcMath::add($this->_paid_to_date_deleted, $net_deletable, 2);
$paymentable_invoice = $paymentable_invoice->fresh();
/** For cancelled invoices, we only reduce the paid to date - balance never changes */
if ($paymentable_invoice->status_id == Invoice::STATUS_CANCELLED) {
$is_trashed = false;
if ($paymentable_invoice->trashed()) {
$is_trashed = true;
$paymentable_invoice->restore();
}
$paymentable_invoice->service()
->updatePaidToDate(BcMath::mul($net_deletable, -1, 2))
->save();
if($net_deletable > 0) {
$this->payment
->client
->service()
->updatePaidToDate(BcMath::mul($net_deletable, -1, 2))
->save();
}
if ($is_trashed) {
$paymentable_invoice->delete();
}
}
elseif(!$paymentable_invoice->is_deleted) {
$paymentable_invoice->restore();
$paymentable_invoice->service()
->updateBalance($net_deletable)
->updatePaidToDate(BcMath::mul($net_deletable, -1, 2))
->save();
$paymentable_invoice->ledger()
->updateInvoiceBalance($net_deletable, "Adjusting invoice {$paymentable_invoice->number} due to deletion of Payment {$this->payment->number}")
->save();
//Negative Payments need to be dealt with differently.
if($net_deletable > 0) {
$_applicable_paid_to_date = BcMath::mul($net_deletable, -1, 2);
}
else {
$_applicable_paid_to_date = '0';
}
$this->payment
->client
->service()
->updateBalanceAndPaidToDate($net_deletable, $_applicable_paid_to_date) // if negative, set to 0, the paid to date will be reduced further down.
->save();
if(BcMath::equal($paymentable_invoice->balance, $paymentable_invoice->amount, 2)) {
$paymentable_invoice->service()->setStatus(Invoice::STATUS_SENT)->save();
}
elseif(BcMath::equal($paymentable_invoice->balance, 0, 2)) {
$paymentable_invoice->service()->setStatus(Invoice::STATUS_PAID)->save();
}
else {
$paymentable_invoice->service()->setStatus(Invoice::STATUS_PARTIAL)->save();
}
}
else {
$paymentable_invoice->restore();
$paymentable_invoice->service()
->updatePaidToDate(BcMath::mul($net_deletable, -1, 2))
->save();
$paymentable_invoice->delete();
}
PaymentTransactionEventEntry::dispatch($this->payment, [$paymentable_invoice->id], $this->payment->company->db, $net_deletable, true);
});
}
elseif(BcMath::equal($this->payment->amount, $this->payment->applied, 2)) {
$this->update_client_paid_to_date = false;
}
if($this->update_client_paid_to_date) {
if($this->payment->amount < 0) {
$reduced_paid_to_date = BcMath::mul($this->payment->amount, -1, 2);
}
else{
$_payment_sub = BcMath::sub($this->payment->amount, $this->payment->refunded,2);
$reduced_paid_to_date = min(0, (BcMath::mul(BcMath::sub($_payment_sub, $this->_paid_to_date_deleted,2), -1,2)));
}
/** handle the edge case where a partial credit + unapplied payment is deleted */
if(!BcMath::equal($this->total_payment_amount, $this->_paid_to_date_deleted,2)) {
$reduced_paid_to_date = min(0, BcMath::mul(BcMath::sub($this->total_payment_amount, $this->_paid_to_date_deleted, 2), -1, 2));
}
if(!BcMath::equal($reduced_paid_to_date, '0', 2)) {
$this->payment
->client
->service()
->updatePaidToDate($reduced_paid_to_date)
->save();
}
}
return $this;
}
private function deletePaymentables(): self
{
$this->payment->paymentables()
->each(function ($pp) {
$pp->forceDelete();
});
return $this;
}
}

View File

@ -12,11 +12,12 @@
namespace App\Services\Payment;
use App\Factory\PaymentFactory;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\PaymentHash;
use App\Factory\PaymentFactory;
use App\Utils\Traits\MakesHash;
use App\Services\Payment\DeletePaymentV2;
class PaymentService
{
@ -88,7 +89,7 @@ class PaymentService
public function deletePayment($update_client_paid_to_date = true): ?Payment
{
return (new DeletePayment($this->payment, $update_client_paid_to_date))->run();
return (new DeletePaymentV2($this->payment, $update_client_paid_to_date))->run();
}
public function updateInvoicePayment(PaymentHash $payment_hash): ?Payment

View File

@ -37,6 +37,7 @@
"ext-json": "*",
"ext-libxml": "*",
"ext-simplexml": "*",
"ext-bcmath": "*",
"afosto/yaac": "^1.5",
"asm/php-ansible": "dev-main",
"authorizenet/authorizenet": "^2.0",

View File

@ -1198,6 +1198,7 @@ class ClientApiTest extends TestCase
$arr = $response->json();
nlog($arr);
$this->assertEquals('3', $arr['data']['settings']['language_id']);
}

View File

@ -12,6 +12,7 @@
namespace Tests\Unit\Shop;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\App;
use Tests\MockAccountData;
use Tests\TestCase;
@ -28,7 +29,10 @@ class ShopProfileTest extends TestCase
{
parent::setUp();
App::setLocale('en');
$this->makeTestData();
}
public function testProfileDisplays()