diff --git a/app/Listeners/Payment/PaymentBalanceActivity.php b/app/Listeners/Payment/PaymentBalanceActivity.php index 04e7179f3a..ca5a3c009f 100644 --- a/app/Listeners/Payment/PaymentBalanceActivity.php +++ b/app/Listeners/Payment/PaymentBalanceActivity.php @@ -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; diff --git a/app/Listeners/Payment/PaymentTransactionEventEntry.php b/app/Listeners/Payment/PaymentTransactionEventEntry.php index 89efd3cbee..5e23423daf 100644 --- a/app/Listeners/Payment/PaymentTransactionEventEntry.php +++ b/app/Listeners/Payment/PaymentTransactionEventEntry.php @@ -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() diff --git a/app/Models/BankTransaction.php b/app/Models/BankTransaction.php index 72e691a7b1..c6f07377e0 100644 --- a/app/Models/BankTransaction.php +++ b/app/Models/BankTransaction.php @@ -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 diff --git a/app/PaymentDrivers/BTCPayPaymentDriver.php b/app/PaymentDrivers/BTCPayPaymentDriver.php index 230d548bc5..c70691e682 100644 --- a/app/PaymentDrivers/BTCPayPaymentDriver.php +++ b/app/PaymentDrivers/BTCPayPaymentDriver.php @@ -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); diff --git a/app/Services/Client/ClientService.php b/app/Services/Client/ClientService.php index 7ed1f14d78..198359a45d 100644 --- a/app/Services/Client/ClientService.php +++ b/app/Services/Client/ClientService.php @@ -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; diff --git a/app/Services/Payment/DeletePayment.php b/app/Services/Payment/DeletePayment.php index 1246c113a5..944c50ca8e 100644 --- a/app/Services/Payment/DeletePayment.php +++ b/app/Services/Payment/DeletePayment.php @@ -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; diff --git a/app/Services/Payment/DeletePaymentV2.php b/app/Services/Payment/DeletePaymentV2.php new file mode 100644 index 0000000000..1ba0ddf55b --- /dev/null +++ b/app/Services/Payment/DeletePaymentV2.php @@ -0,0 +1,278 @@ +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; + } + + +} diff --git a/app/Services/Payment/PaymentService.php b/app/Services/Payment/PaymentService.php index a1ac776ea4..ac57a4e7d0 100644 --- a/app/Services/Payment/PaymentService.php +++ b/app/Services/Payment/PaymentService.php @@ -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 diff --git a/composer.json b/composer.json index 62f3bf4059..d0f4610604 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/tests/Feature/ClientApiTest.php b/tests/Feature/ClientApiTest.php index e53d72d115..eee42c560a 100644 --- a/tests/Feature/ClientApiTest.php +++ b/tests/Feature/ClientApiTest.php @@ -1198,6 +1198,7 @@ class ClientApiTest extends TestCase $arr = $response->json(); + nlog($arr); $this->assertEquals('3', $arr['data']['settings']['language_id']); } diff --git a/tests/Unit/Shop/ShopProfileTest.php b/tests/Unit/Shop/ShopProfileTest.php index 89020c5fbf..751f763407 100644 --- a/tests/Unit/Shop/ShopProfileTest.php +++ b/tests/Unit/Shop/ShopProfileTest.php @@ -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()