From 18e46d3c8828e775614dd2c92af70a3d9e44b127 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 4 Sep 2025 09:58:46 +1000 Subject: [PATCH 01/16] protect routes --- app/Http/Controllers/AccountController.php | 4 ++-- app/Http/Controllers/ConnectedAccountController.php | 2 +- app/Http/Requests/Account/CreateAccountRequest.php | 2 +- routes/api.php | 4 ++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 993e733175..c5820ba29d 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -86,8 +86,8 @@ class AccountController extends BaseController } - if ($request->has('hash') && config('ninja.cloudflare.turnstile.secret')) { //@todo once all platforms are implemented, we disable access to the rest of this route without a success response. - + if ($request->has('hash') && config('ninja.cloudflare.turnstile.secret')) { + if (Secure::decrypt($request->input('hash')) !== $request->input('email')) { return response()->json(['message' => 'Invalid Signup Payload'], 400); } diff --git a/app/Http/Controllers/ConnectedAccountController.php b/app/Http/Controllers/ConnectedAccountController.php index 2748b6d6b2..d08372a158 100644 --- a/app/Http/Controllers/ConnectedAccountController.php +++ b/app/Http/Controllers/ConnectedAccountController.php @@ -111,7 +111,7 @@ class ConnectedAccountController extends BaseController nlog("microsoft"); nlog($email); - if (auth()->user()->email != $email && MultiDB::checkUserEmailExists($email)) { + if (strtolower(auth()->user()->email) != strtolower($email) && MultiDB::checkUserEmailExists(strtolower($email))) { return response()->json(['message' => ctrans('texts.email_already_register')], 400); } diff --git a/app/Http/Requests/Account/CreateAccountRequest.php b/app/Http/Requests/Account/CreateAccountRequest.php index 2c39856c21..ac488dd0b5 100644 --- a/app/Http/Requests/Account/CreateAccountRequest.php +++ b/app/Http/Requests/Account/CreateAccountRequest.php @@ -62,7 +62,7 @@ class CreateAccountRequest extends Request public function prepareForValidation() { - nlog(array_merge(['signup' => 'true', 'ipaddy' => request()->ip()], $this->all())); + nlog(array_merge(['signup' => 'true', 'ipaddy' => request()->ip(), 'headers' => request()->headers->all()], $this->all())); $input = $this->all(); diff --git a/routes/api.php b/routes/api.php index 788e46fc79..fdf16e49a2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -514,3 +514,7 @@ Route::get('/health', function () { 'message' => 'API is healthy', ]); })->middleware('throttle:20,1'); + +Route::get('/api/v1/signup/protect', function () { + return response()->json(['status' => 'ok']); +})->middleware('throttle:10,1'); \ No newline at end of file From 4d63b1336ace8b6633ca1c91c492f0c01496a29c Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 4 Sep 2025 10:41:57 +1000 Subject: [PATCH 02/16] minor code formatting --- resources/views/auth/passwords/reset.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/auth/passwords/reset.blade.php b/resources/views/auth/passwords/reset.blade.php index 664ef7838a..b22748f253 100644 --- a/resources/views/auth/passwords/reset.blade.php +++ b/resources/views/auth/passwords/reset.blade.php @@ -12,7 +12,7 @@
@csrf - +

trans('texts.change_password')

From f0af52c01771cb0749e78d161ae8732ec613451f Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 4 Sep 2025 11:41:06 +1000 Subject: [PATCH 03/16] fixes for double encoding --- .../Auth/ResetPasswordController.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index bb84d17abb..2a42b720a1 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -84,6 +84,24 @@ class ResetPasswordController extends Controller */ public function reset(Request $request) { + // Safely decode URL-encoded token and email before validation + if ($request->has('token')) { + $token = $request->input('token'); + // Only decode if it contains URL encoding characters + if (strpos($token, '%') !== false) { + $request->merge(['token' => urldecode($token)]); + } + } + + if ($request->has('email')) { + $email = $request->input('email'); + // Only decode if it contains URL encoding characters + if (strpos($email, '%') !== false) { + $request->merge(['email' => urldecode($email)]); + } + + } + $request->validate($this->rules(), $this->validationErrorMessages()); // Here we will attempt to reset the user's password. If it is successful we From f02c6bb2771e2a30a21e3fdc5446f9c23d34fc46 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 5 Sep 2025 13:53:30 +1000 Subject: [PATCH 04/16] Change approach for update client balance logic --- app/Services/Client/ClientService.php | 25 +++---- composer.lock | 94 +++++++++++++-------------- 2 files changed, 61 insertions(+), 58 deletions(-) diff --git a/app/Services/Client/ClientService.php b/app/Services/Client/ClientService.php index 624059f0e3..98dbeecda4 100644 --- a/app/Services/Client/ClientService.php +++ b/app/Services/Client/ClientService.php @@ -45,28 +45,31 @@ class ClientService public function calculateBalance(?Invoice $invoice = null) { - $balance = Invoice::withTrashed() - ->where('client_id', $this->client->id) - ->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) - ->where('is_deleted', false) - ->sum('balance'); - - $pre_client_balance = $this->client->balance; + // $pre_client_balance = $this->client->balance; try { + + $balance = Invoice::withTrashed() + ->where('client_id', $this->client->id) + ->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) + ->where('is_deleted', false) + ->sum('balance'); + DB::connection(config('database.default'))->transaction(function () use ($balance) { $this->client = Client::withTrashed()->where('id', $this->client->id)->lockForUpdate()->first(); $this->client->balance = $balance; $this->client->saveQuietly(); }, 2); + + } catch (\Throwable $throwable) { nlog("DB ERROR " . $throwable->getMessage()); } - if ($invoice && floatval($this->client->balance) != floatval($pre_client_balance)) { - $diff = $this->client->balance - $pre_client_balance; - $invoice->ledger()->insertInvoiceBalance($diff, $this->client->balance, "Update Adjustment Invoice # {$invoice->number} => {$diff}"); - } + // if ($invoice && floatval($this->client->balance) != floatval($pre_client_balance)) { + // $diff = $this->client->balance - $pre_client_balance; + // $invoice->ledger()->insertInvoiceBalance($diff, $this->client->balance, "Update Adjustment Invoice # {$invoice->number} => {$diff}"); + // } return $this; } diff --git a/composer.lock b/composer.lock index 08eb077d3e..2c7dfe7bef 100644 --- a/composer.lock +++ b/composer.lock @@ -1119,25 +1119,25 @@ }, { "name": "brick/math", - "version": "0.13.1", + "version": "0.14.0", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "url": "https://api.github.com/repos/brick/math/zipball/113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", + "reference": "113a8ee2656b882d4c3164fa31aa6e12cbb7aaa2", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -1167,7 +1167,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.13.1" + "source": "https://github.com/brick/math/tree/0.14.0" }, "funding": [ { @@ -1175,7 +1175,7 @@ "type": "github" } ], - "time": "2025-03-29T13:50:30+00:00" + "time": "2025-08-29T12:40:03+00:00" }, { "name": "btcpayserver/btcpayserver-greenfield-php", @@ -8518,16 +8518,16 @@ }, { "name": "open-telemetry/api", - "version": "1.4.0", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/api.git", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7" + "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", - "reference": "b3a9286f9c1c8247c83493c5b1fa475cd0cec7f7", + "url": "https://api.github.com/repos/opentelemetry-php/api/zipball/7692075f486c14d8cfd37fba98a08a5667f089e5", + "reference": "7692075f486c14d8cfd37fba98a08a5667f089e5", "shasum": "" }, "require": { @@ -8584,7 +8584,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-06-19T23:36:51+00:00" + "time": "2025-08-07T23:07:38+00:00" }, { "name": "open-telemetry/context", @@ -9521,16 +9521,16 @@ }, { "name": "phpoffice/phpspreadsheet", - "version": "2.4.0", + "version": "2.4.1", "source": { "type": "git", "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", - "reference": "3a3cad86101a77019eb2fc693aab1a8c11b18b94" + "reference": "096ae6faf94b49b2cf53e92a0073133c941e1f57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3a3cad86101a77019eb2fc693aab1a8c11b18b94", - "reference": "3a3cad86101a77019eb2fc693aab1a8c11b18b94", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/096ae6faf94b49b2cf53e92a0073133c941e1f57", + "reference": "096ae6faf94b49b2cf53e92a0073133c941e1f57", "shasum": "" }, "require": { @@ -9620,9 +9620,9 @@ ], "support": { "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", - "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.0" + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/2.4.1" }, - "time": "2025-08-10T06:45:13+00:00" + "time": "2025-09-01T18:41:37+00:00" }, { "name": "phpoption/phpoption", @@ -10752,20 +10752,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.0", + "version": "4.9.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0" + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/4e0e23cc785f0724a0e838279a9eb03f28b092a0", - "reference": "4e0e23cc785f0724a0e838279a9eb03f28b092a0", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13", + "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -10824,9 +10824,9 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.0" + "source": "https://github.com/ramsey/uuid/tree/4.9.1" }, - "time": "2025-06-25T14:20:11+00:00" + "time": "2025-09-04T20:59:21+00:00" }, { "name": "razorpay/razorpay", @@ -11266,23 +11266,23 @@ }, { "name": "sentry/sentry-laravel", - "version": "4.15.1", + "version": "4.15.3", "source": { "type": "git", "url": "https://github.com/getsentry/sentry-laravel.git", - "reference": "7e0675e8e06d1ec5cb623792892920000a3aedb5" + "reference": "c3f71a83e8b3a1451e811199d145e864519cecc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/7e0675e8e06d1ec5cb623792892920000a3aedb5", - "reference": "7e0675e8e06d1ec5cb623792892920000a3aedb5", + "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/c3f71a83e8b3a1451e811199d145e864519cecc1", + "reference": "c3f71a83e8b3a1451e811199d145e864519cecc1", "shasum": "" }, "require": { "illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0", "nyholm/psr7": "^1.0", "php": "^7.2 | ^8.0", - "sentry/sentry": "^4.14.1", + "sentry/sentry": "^4.15.2", "symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0" }, "require-dev": { @@ -11339,7 +11339,7 @@ ], "support": { "issues": "https://github.com/getsentry/sentry-laravel/issues", - "source": "https://github.com/getsentry/sentry-laravel/tree/4.15.1" + "source": "https://github.com/getsentry/sentry-laravel/tree/4.15.3" }, "funding": [ { @@ -11351,7 +11351,7 @@ "type": "custom" } ], - "time": "2025-06-24T12:39:03+00:00" + "time": "2025-09-04T14:37:41+00:00" }, { "name": "setasign/fpdf", @@ -18084,16 +18084,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.87.0", + "version": "v3.87.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "50a13c4c5f25d2c6894e30e92c051474cf0e115a" + "reference": "2f5170365e2a422d0c5421f9c8818b2c078105f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/50a13c4c5f25d2c6894e30e92c051474cf0e115a", - "reference": "50a13c4c5f25d2c6894e30e92c051474cf0e115a", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/2f5170365e2a422d0c5421f9c8818b2c078105f6", + "reference": "2f5170365e2a422d0c5421f9c8818b2c078105f6", "shasum": "" }, "require": { @@ -18126,7 +18126,7 @@ "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.7", "infection/infection": "^0.29.14", - "justinrainbow/json-schema": "^6.4", + "justinrainbow/json-schema": "^6.5", "keradus/cli-executor": "^2.2", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.8", @@ -18176,7 +18176,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v97773{PHP_CS_FIXER_VERSION}" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.87.1" }, "funding": [ { @@ -18184,7 +18184,7 @@ "type": "github" } ], - "time": "2025-09-02T10:58:35+00:00" + "time": "2025-09-02T15:27:36+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -19194,16 +19194,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.35", + "version": "11.5.36", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d341ee94ee5007b286fc7907b383aae6b5b3cc91" + "reference": "264a87c7ef68b1ab9af7172357740dc266df5957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d341ee94ee5007b286fc7907b383aae6b5b3cc91", - "reference": "d341ee94ee5007b286fc7907b383aae6b5b3cc91", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/264a87c7ef68b1ab9af7172357740dc266df5957", + "reference": "264a87c7ef68b1ab9af7172357740dc266df5957", "shasum": "" }, "require": { @@ -19275,7 +19275,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.35" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.36" }, "funding": [ { @@ -19299,7 +19299,7 @@ "type": "tidelift" } ], - "time": "2025-08-28T05:13:54+00:00" + "time": "2025-09-03T06:24:17+00:00" }, { "name": "react/cache", From d3f6f642f24e05eb5ae25cb19a741ba8213172e0 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 5 Sep 2025 14:39:15 +1000 Subject: [PATCH 05/16] comparables --- app/Services/Payment/DeletePayment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/Payment/DeletePayment.php b/app/Services/Payment/DeletePayment.php index 401fd70ce9..1d4a8af8e3 100644 --- a/app/Services/Payment/DeletePayment.php +++ b/app/Services/Payment/DeletePayment.php @@ -154,7 +154,7 @@ class DeletePayment if (abs(floatval($paymentable_invoice->balance) - floatval($paymentable_invoice->amount)) < 0.005) { $paymentable_invoice->service()->setStatus(Invoice::STATUS_SENT)->save(); - } elseif (floatval($paymentable_invoice->balance) == 0) { + } elseif (abs(floatval($paymentable_invoice->balance)) < 0.005) { $paymentable_invoice->service()->setStatus(Invoice::STATUS_PAID)->save(); } else { $paymentable_invoice->service()->setStatus(Invoice::STATUS_PARTIAL)->save(); From 2222361ccadce0b4c1b83a880537604d750aa8e6 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 5 Sep 2025 14:41:21 +1000 Subject: [PATCH 06/16] transaction calculations --- app/Services/Client/ClientService.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/Services/Client/ClientService.php b/app/Services/Client/ClientService.php index 98dbeecda4..7ed1f14d78 100644 --- a/app/Services/Client/ClientService.php +++ b/app/Services/Client/ClientService.php @@ -119,14 +119,16 @@ class ClientService public function updatePaymentBalance() { - $amount = Payment::query() - ->withTrashed() - ->where('client_id', $this->client->id) - ->where('is_deleted', 0) - ->whereIn('status_id', [Payment::STATUS_COMPLETED, Payment::STATUS_PENDING, Payment::STATUS_PARTIALLY_REFUNDED, Payment::STATUS_REFUNDED]) - ->selectRaw('SUM(payments.amount - payments.applied) as amount')->first()->amount ?? 0; - DB::connection(config('database.default'))->transaction(function () use ($amount) { + DB::connection(config('database.default'))->transaction(function () { + + $amount = Payment::query() + ->withTrashed() + ->where('client_id', $this->client->id) + ->where('is_deleted', 0) + ->whereIn('status_id', [Payment::STATUS_COMPLETED, Payment::STATUS_PENDING, Payment::STATUS_PARTIALLY_REFUNDED, Payment::STATUS_REFUNDED]) + ->selectRaw('SUM(payments.amount - payments.applied) as amount')->first()->amount ?? 0; + $this->client = Client::withTrashed()->where('id', $this->client->id)->lockForUpdate()->first(); $this->client->payment_balance = $amount; $this->client->saveQuietly(); From bf3e802dccc721fc35b6dabed20d11f3db93edd5 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 5 Sep 2025 19:10:13 +1000 Subject: [PATCH 07/16] Testing BCMatch --- app/Services/Payment/DeletePayment.php | 59 ++++++++++++++++++-------- 1 file changed, 42 insertions(+), 17 deletions(-) diff --git a/app/Services/Payment/DeletePayment.php b/app/Services/Payment/DeletePayment.php index 1d4a8af8e3..3324350936 100644 --- a/app/Services/Payment/DeletePayment.php +++ b/app/Services/Payment/DeletePayment.php @@ -12,6 +12,7 @@ namespace App\Services\Payment; +use App\Utils\BcMath; use App\Models\Credit; use App\Models\Invoice; use App\Models\Payment; @@ -21,7 +22,7 @@ use Illuminate\Contracts\Container\BindingResolutionException; class DeletePayment { - private float $_paid_to_date_deleted = 0; + private string $_paid_to_date_deleted = "0"; private float $total_payment_amount = 0; /** @@ -86,7 +87,7 @@ class DeletePayment /** @return $this */ private function adjustInvoices() { - $this->_paid_to_date_deleted = 0; + $this->_paid_to_date_deleted = "0"; if ($this->payment->invoices()->exists()) { @@ -95,9 +96,10 @@ class DeletePayment $this->total_payment_amount = ($this->payment->amount-$this->payment->refunded) + ($this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('amount') - $this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('refunded')); $this->payment->invoices()->each(function ($paymentable_invoice) { - $net_deletable = $paymentable_invoice->pivot->amount - $paymentable_invoice->pivot->refunded; + $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); - $this->_paid_to_date_deleted += $net_deletable; $paymentable_invoice = $paymentable_invoice->fresh(); nlog("net deletable amount - refunded = {$net_deletable}"); @@ -112,15 +114,20 @@ class DeletePayment } $paymentable_invoice->service() - ->updatePaidToDate($net_deletable * -1) + ->updatePaidToDate(BcMath::mul($net_deletable, "-1", 2)) ->save(); // 2025-03-26 - If we are deleting a negative payment, then there is an edge case where the paid to date will be reduced further down. // for this scenario, we skip the update to the client paid to date at this point. + + $negative_net_deletable = BcMath::mul($net_deletable, '-1'); + $paid_to_date_adjustment = BcMath::greaterThan($negative_net_deletable, '0') + ? '0' + : $negative_net_deletable; $this->payment ->client ->service() - ->updatePaidToDate(($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1)) // if negative, set to 0, the paid to date will be reduced further down. + ->updatePaidToDate($paid_to_date_adjustment) // if negative, set to 0, the paid to date will be reduced further down. ->save(); if ($is_trashed) { @@ -133,7 +140,7 @@ class DeletePayment $paymentable_invoice->service() ->updateBalance($net_deletable) - ->updatePaidToDate($net_deletable * -1) + ->updatePaidToDate(BcMath::mul($net_deletable,'-1')) ->save(); $paymentable_invoice->ledger() @@ -144,17 +151,28 @@ class DeletePayment // 2025-03-26 - If we are deleting a negative payment, then there is an edge case where the paid to date will be reduced further down. // for this scenario, we skip the update to the client paid to date at this point. + $negative_net_deletable = BcMath::mul($net_deletable, '-1'); + + // Determine the paid to date adjustment + $paid_to_date_adjustment = BcMath::greaterThan($negative_net_deletable, '0') + ? '0' + : $negative_net_deletable; + //2025-08-19 - if there is an unapplied amount, we need to subtract it from the paid to date. $this->payment ->client ->service() - ->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 )) // if negative, set to 0, the paid to date will be reduced further down. + ->updateBalanceAndPaidToDate( + BcMath::toFloat($net_deletable), + BcMath::toFloat($paid_to_date_adjustment) + ) // if negative, set to 0, the paid to date will be reduced further down. // ->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 - ($this->payment->amount - $this->payment->applied))) // if negative, set to 0, the paid to date will be reduced further down. ->save(); - if (abs(floatval($paymentable_invoice->balance) - floatval($paymentable_invoice->amount)) < 0.005) { + // Corrected BcMath conversion + if (BcMath::equal($paymentable_invoice->balance, $paymentable_invoice->amount)) { $paymentable_invoice->service()->setStatus(Invoice::STATUS_SENT)->save(); - } elseif (abs(floatval($paymentable_invoice->balance)) < 0.005) { + } elseif (BcMath::equal($paymentable_invoice->balance, '0.00', 2)) { $paymentable_invoice->service()->setStatus(Invoice::STATUS_PAID)->save(); } else { $paymentable_invoice->service()->setStatus(Invoice::STATUS_PARTIAL)->save(); @@ -162,7 +180,7 @@ class DeletePayment } else { $paymentable_invoice->restore(); $paymentable_invoice->service() - ->updatePaidToDate($net_deletable * -1) + ->updatePaidToDate(BcMath::mul($net_deletable, '-1')) ->save(); $paymentable_invoice->delete(); @@ -173,8 +191,7 @@ class DeletePayment }); } - elseif(floatval($this->payment->amount) == floatval($this->payment->applied)) { - // If there are no invoices associated with the payment, we should not be updating the clients paid to date amount + elseif(BcMath::equal($this->payment->amount, $this->payment->applied)) { // If there are no invoices associated with the payment, we should not be updating the clients paid to date amount // The edge case handled here is when an invoice has been "reversed" an associated credit note is created, this is effectively the same // payment which can then be used _again_. So the first payment of a reversed invoice should NEVER reduce the paid to date amount. $this->update_client_paid_to_date = false; @@ -187,8 +204,10 @@ class DeletePayment $reduced_paid_to_date = $this->payment->amount < 0 ? $this->payment->amount * -1 : min(0, ($this->payment->amount - $this->payment->refunded - $this->_paid_to_date_deleted) * -1); /** handle the edge case where a partial credit + unapplied payment is deleted */ - if(floatval($this->total_payment_amount) != floatval($this->_paid_to_date_deleted)) { - $reduced_paid_to_date = min(0,($this->total_payment_amount - $this->_paid_to_date_deleted) * -1); + if(!BcMath::equal($this->total_payment_amount, $this->_paid_to_date_deleted)) { + $reduced_paid_to_date = BcMath::toFloat( + BcMath::min('0', BcMath::mul(BcMath::sub($this->total_payment_amount, $this->_paid_to_date_deleted), '-1')) + ); } nlog("reduced paid to date: {$reduced_paid_to_date}"); @@ -215,9 +234,15 @@ class DeletePayment $multiplier = -1; } + // Step-by-step BcMath calculation + $base_amount = $paymentable_credit->pivot->amount; + $multiplied_amount = BcMath::mul($base_amount, (string)$multiplier); + $adjustment_amount = BcMath::mul($multiplied_amount, '-1'); + + // Use for both operations $paymentable_credit->service() - ->updateBalance($paymentable_credit->pivot->amount * $multiplier * -1) - ->updatePaidToDate($paymentable_credit->pivot->amount * $multiplier * -1) + ->updateBalance(BcMath::toFloat($adjustment_amount)) + ->updatePaidToDate(BcMath::toFloat($adjustment_amount)) ->setStatus(Credit::STATUS_SENT) ->save(); From f7250f643a68c88537674aab5edcc15712ac950e Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 07:47:16 +1000 Subject: [PATCH 08/16] New bulk task options --- app/Services/Payment/DeletePayment.php | 5 ++--- app/Utils/Statics.php | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Services/Payment/DeletePayment.php b/app/Services/Payment/DeletePayment.php index 3324350936..404afb18c0 100644 --- a/app/Services/Payment/DeletePayment.php +++ b/app/Services/Payment/DeletePayment.php @@ -205,9 +205,8 @@ class DeletePayment /** handle the edge case where a partial credit + unapplied payment is deleted */ if(!BcMath::equal($this->total_payment_amount, $this->_paid_to_date_deleted)) { - $reduced_paid_to_date = BcMath::toFloat( - BcMath::min('0', BcMath::mul(BcMath::sub($this->total_payment_amount, $this->_paid_to_date_deleted), '-1')) - ); + $reduced_paid_to_date = + min(0, BcMath::toFloat(BcMath::mul(BcMath::sub($this->total_payment_amount, $this->_paid_to_date_deleted), '-1'))); } nlog("reduced paid to date: {$reduced_paid_to_date}"); diff --git a/app/Utils/Statics.php b/app/Utils/Statics.php index c82d7ec44e..dbbe3d3b79 100644 --- a/app/Utils/Statics.php +++ b/app/Utils/Statics.php @@ -127,6 +127,7 @@ class Statics 'client' => \App\Models\Client::$bulk_update_columns, 'expense' => \App\Models\Expense::$bulk_update_columns, 'recurring_invoice' => \App\Models\RecurringInvoice::$bulk_update_columns, + 'task' => \App\Models\Task::$bulk_update_columns, ]; return $data; From 1f62b24e25b61faef88252e356447603c2dd9708 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 13:20:55 +1000 Subject: [PATCH 09/16] New bulk task options --- app/Http/Controllers/TaskController.php | 6 ++++-- app/Repositories/TaskRepository.php | 18 ++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index 1cc28e55ee..f62913fc6b 100644 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -514,11 +514,13 @@ class TaskController extends BaseController $tasks = Task::withTrashed()->whereIn('id', $this->transformKeys($ids))->company(); - if ($request->action == 'bulk_update' && $user->can('edit', $tasks->first())) { + $_tasks = (clone $tasks); + + if ($request->action == 'bulk_update' && $user->can('edit', $_tasks->first())) { $this->task_repo->bulkUpdate($tasks, $request->column, $request->new_value); - return $this->listResponse(Task::withTrashed()->whereIn('id', $this->transformKeys($ids))); + return $this->listResponse(Task::withTrashed()->whereIn('id', $this->transformKeys($ids))->company()); } diff --git a/app/Repositories/TaskRepository.php b/app/Repositories/TaskRepository.php index 93c31d0131..ebd8f16b3f 100644 --- a/app/Repositories/TaskRepository.php +++ b/app/Repositories/TaskRepository.php @@ -16,6 +16,7 @@ use App\Models\Task; use App\Models\Project; use App\Factory\TaskFactory; use App\Jobs\Task\TaskAssigned; +use App\Utils\Traits\MakesHash; use App\Utils\Traits\GeneratesCounter; use Illuminate\Database\QueryException; @@ -25,6 +26,7 @@ use Illuminate\Database\QueryException; class TaskRepository extends BaseRepository { use GeneratesCounter; + use MakesHash; public $new_task = true; @@ -432,9 +434,21 @@ class TaskRepository extends BaseRepository public function bulkUpdate(\Illuminate\Database\Eloquent\Builder $models, string $column, mixed $new_value): void { +nlog("xx"); + nlog($models->pluck('id')); +nlog("yy"); + // First, filter out tasks that have been invoiced $models->whereNull('invoice_id'); - + + nlog($models->pluck('id')); + + if(stripos($column, '_id') !== false) { + $new_value = $this->decodePrimaryKey($new_value); + } + + nlog("setting column = " . $column . " to " . $new_value); + if ($column === 'project_id') { // Handle project_id updates with client_id synchronization $project = Project::withTrashed() @@ -449,7 +463,7 @@ class TaskRepository extends BaseRepository 'client_id' => $project->client_id, ]); } - } elseif ($column === 'client_id') { + } elseif ($column === 'client_id') { // If you are updating the client - we will unset the project id! $models->update([$column => $new_value, 'project_id' => null]); } From afa4bc461ca03df88399f4b50ad03d7a2a404f70 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 13:26:10 +1000 Subject: [PATCH 10/16] Fixes for tests --- app/Repositories/TaskRepository.php | 7 ------- tests/Unit/TaskRepositoryBulkUpdateTest.php | 16 ++++++++-------- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/app/Repositories/TaskRepository.php b/app/Repositories/TaskRepository.php index ebd8f16b3f..e34ac8723a 100644 --- a/app/Repositories/TaskRepository.php +++ b/app/Repositories/TaskRepository.php @@ -434,21 +434,14 @@ class TaskRepository extends BaseRepository public function bulkUpdate(\Illuminate\Database\Eloquent\Builder $models, string $column, mixed $new_value): void { -nlog("xx"); - nlog($models->pluck('id')); -nlog("yy"); // First, filter out tasks that have been invoiced $models->whereNull('invoice_id'); - nlog($models->pluck('id')); - if(stripos($column, '_id') !== false) { $new_value = $this->decodePrimaryKey($new_value); } - nlog("setting column = " . $column . " to " . $new_value); - if ($column === 'project_id') { // Handle project_id updates with client_id synchronization $project = Project::withTrashed() diff --git a/tests/Unit/TaskRepositoryBulkUpdateTest.php b/tests/Unit/TaskRepositoryBulkUpdateTest.php index 1153dc2eae..53779af14a 100644 --- a/tests/Unit/TaskRepositoryBulkUpdateTest.php +++ b/tests/Unit/TaskRepositoryBulkUpdateTest.php @@ -102,7 +102,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $models = Task::whereIn('id', [$task1->id, $task2->id]); // Bulk update project_id - $this->taskRepository->bulkUpdate($models, 'project_id', $otherProject->id); + $this->taskRepository->bulkUpdate($models, 'project_id', $otherProject->hashed_id); // Refresh models from database $task1->refresh(); @@ -164,7 +164,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $models = Task::where('id', $task->id); // Bulk update client_id - $this->taskRepository->bulkUpdate($models, 'client_id', $newClient->id); + $this->taskRepository->bulkUpdate($models, 'client_id', $newClient->hashed_id); // Refresh model from database $task->refresh(); @@ -197,7 +197,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $models = Task::whereIn('id', [$task1->id, $task2->id]); // Bulk update assigned_user_id - $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->hashed_id); // Refresh models from database $task1->refresh(); @@ -238,7 +238,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $models = Task::whereIn('id', [$invoicedTask->id, $regularTask->id]); // Bulk update assigned_user_id - $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->hashed_id); // Refresh models from database $invoicedTask->refresh(); @@ -269,7 +269,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $models = Task::where('id', $task->id); // Bulk update project_id (should work with soft deleted project) - $this->taskRepository->bulkUpdate($models, 'project_id', $this->testProject->id); + $this->taskRepository->bulkUpdate($models, 'project_id', $this->testProject->hashed_id); // Refresh model from database $task->refresh(); @@ -324,7 +324,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $startTime = microtime(true); // Bulk update assigned_user_id - $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->hashed_id); $endTime = microtime(true); $executionTime = $endTime - $startTime; @@ -345,7 +345,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $models = Task::where('id', 99999); // This should not throw an error - $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->id); + $this->taskRepository->bulkUpdate($models, 'assigned_user_id', $this->testUser->hashed_id); // No assertions needed - just ensuring no exceptions are thrown $this->assertTrue(true); @@ -369,7 +369,7 @@ class TaskRepositoryBulkUpdateTest extends TestCase $models = Task::where('id', $task->id); // Bulk update project_id (should work with soft deleted project) - $this->taskRepository->bulkUpdate($models, 'project_id', $this->testProject->id); + $this->taskRepository->bulkUpdate($models, 'project_id', $this->testProject->hashed_id); // Refresh model from database $task->refresh(); From f6aac6b4249fc2af4cd0e248216739bf4de3c2c3 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 15:27:35 +1000 Subject: [PATCH 11/16] Rollback BCMath --- app/Services/Payment/DeletePayment.php | 60 ++++++++------------------ 1 file changed, 18 insertions(+), 42 deletions(-) diff --git a/app/Services/Payment/DeletePayment.php b/app/Services/Payment/DeletePayment.php index 404afb18c0..1246c113a5 100644 --- a/app/Services/Payment/DeletePayment.php +++ b/app/Services/Payment/DeletePayment.php @@ -12,7 +12,6 @@ namespace App\Services\Payment; -use App\Utils\BcMath; use App\Models\Credit; use App\Models\Invoice; use App\Models\Payment; @@ -22,7 +21,7 @@ use Illuminate\Contracts\Container\BindingResolutionException; class DeletePayment { - private string $_paid_to_date_deleted = "0"; + private float $_paid_to_date_deleted = 0; private float $total_payment_amount = 0; /** @@ -87,7 +86,7 @@ class DeletePayment /** @return $this */ private function adjustInvoices() { - $this->_paid_to_date_deleted = "0"; + $this->_paid_to_date_deleted = 0; if ($this->payment->invoices()->exists()) { @@ -96,10 +95,9 @@ class DeletePayment $this->total_payment_amount = ($this->payment->amount-$this->payment->refunded) + ($this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('amount') - $this->payment->paymentables->where('paymentable_type', 'App\Models\Credit')->sum('refunded')); $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); + $net_deletable = $paymentable_invoice->pivot->amount - $paymentable_invoice->pivot->refunded; + $this->_paid_to_date_deleted += $net_deletable; $paymentable_invoice = $paymentable_invoice->fresh(); nlog("net deletable amount - refunded = {$net_deletable}"); @@ -114,20 +112,15 @@ class DeletePayment } $paymentable_invoice->service() - ->updatePaidToDate(BcMath::mul($net_deletable, "-1", 2)) + ->updatePaidToDate($net_deletable * -1) ->save(); // 2025-03-26 - If we are deleting a negative payment, then there is an edge case where the paid to date will be reduced further down. // for this scenario, we skip the update to the client paid to date at this point. - - $negative_net_deletable = BcMath::mul($net_deletable, '-1'); - $paid_to_date_adjustment = BcMath::greaterThan($negative_net_deletable, '0') - ? '0' - : $negative_net_deletable; $this->payment ->client ->service() - ->updatePaidToDate($paid_to_date_adjustment) // if negative, set to 0, the paid to date will be reduced further down. + ->updatePaidToDate(($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1)) // if negative, set to 0, the paid to date will be reduced further down. ->save(); if ($is_trashed) { @@ -140,7 +133,7 @@ class DeletePayment $paymentable_invoice->service() ->updateBalance($net_deletable) - ->updatePaidToDate(BcMath::mul($net_deletable,'-1')) + ->updatePaidToDate($net_deletable * -1) ->save(); $paymentable_invoice->ledger() @@ -151,28 +144,17 @@ class DeletePayment // 2025-03-26 - If we are deleting a negative payment, then there is an edge case where the paid to date will be reduced further down. // for this scenario, we skip the update to the client paid to date at this point. - $negative_net_deletable = BcMath::mul($net_deletable, '-1'); - - // Determine the paid to date adjustment - $paid_to_date_adjustment = BcMath::greaterThan($negative_net_deletable, '0') - ? '0' - : $negative_net_deletable; - //2025-08-19 - if there is an unapplied amount, we need to subtract it from the paid to date. $this->payment ->client ->service() - ->updateBalanceAndPaidToDate( - BcMath::toFloat($net_deletable), - BcMath::toFloat($paid_to_date_adjustment) - ) // if negative, set to 0, the paid to date will be reduced further down. + ->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 )) // if negative, set to 0, the paid to date will be reduced further down. // ->updateBalanceAndPaidToDate($net_deletable, ($net_deletable * -1) > 0 ? 0 : ($net_deletable * -1 - ($this->payment->amount - $this->payment->applied))) // if negative, set to 0, the paid to date will be reduced further down. ->save(); - // Corrected BcMath conversion - if (BcMath::equal($paymentable_invoice->balance, $paymentable_invoice->amount)) { + if (abs(floatval($paymentable_invoice->balance) - floatval($paymentable_invoice->amount)) < 0.005) { $paymentable_invoice->service()->setStatus(Invoice::STATUS_SENT)->save(); - } elseif (BcMath::equal($paymentable_invoice->balance, '0.00', 2)) { + } elseif (abs(floatval($paymentable_invoice->balance)) < 0.005) { $paymentable_invoice->service()->setStatus(Invoice::STATUS_PAID)->save(); } else { $paymentable_invoice->service()->setStatus(Invoice::STATUS_PARTIAL)->save(); @@ -180,7 +162,7 @@ class DeletePayment } else { $paymentable_invoice->restore(); $paymentable_invoice->service() - ->updatePaidToDate(BcMath::mul($net_deletable, '-1')) + ->updatePaidToDate($net_deletable * -1) ->save(); $paymentable_invoice->delete(); @@ -191,7 +173,8 @@ class DeletePayment }); } - elseif(BcMath::equal($this->payment->amount, $this->payment->applied)) { // If there are no invoices associated with the payment, we should not be updating the clients paid to date amount + elseif(floatval($this->payment->amount) == floatval($this->payment->applied)) { + // If there are no invoices associated with the payment, we should not be updating the clients paid to date amount // The edge case handled here is when an invoice has been "reversed" an associated credit note is created, this is effectively the same // payment which can then be used _again_. So the first payment of a reversed invoice should NEVER reduce the paid to date amount. $this->update_client_paid_to_date = false; @@ -204,9 +187,8 @@ class DeletePayment $reduced_paid_to_date = $this->payment->amount < 0 ? $this->payment->amount * -1 : min(0, ($this->payment->amount - $this->payment->refunded - $this->_paid_to_date_deleted) * -1); /** handle the edge case where a partial credit + unapplied payment is deleted */ - if(!BcMath::equal($this->total_payment_amount, $this->_paid_to_date_deleted)) { - $reduced_paid_to_date = - min(0, BcMath::toFloat(BcMath::mul(BcMath::sub($this->total_payment_amount, $this->_paid_to_date_deleted), '-1'))); + if(floatval($this->total_payment_amount) != floatval($this->_paid_to_date_deleted)) { + $reduced_paid_to_date = min(0,($this->total_payment_amount - $this->_paid_to_date_deleted) * -1); } nlog("reduced paid to date: {$reduced_paid_to_date}"); @@ -233,15 +215,9 @@ class DeletePayment $multiplier = -1; } - // Step-by-step BcMath calculation - $base_amount = $paymentable_credit->pivot->amount; - $multiplied_amount = BcMath::mul($base_amount, (string)$multiplier); - $adjustment_amount = BcMath::mul($multiplied_amount, '-1'); - - // Use for both operations $paymentable_credit->service() - ->updateBalance(BcMath::toFloat($adjustment_amount)) - ->updatePaidToDate(BcMath::toFloat($adjustment_amount)) + ->updateBalance($paymentable_credit->pivot->amount * $multiplier * -1) + ->updatePaidToDate($paymentable_credit->pivot->amount * $multiplier * -1) ->setStatus(Credit::STATUS_SENT) ->save(); @@ -279,4 +255,4 @@ class DeletePayment return $this->payment; } -} +} \ No newline at end of file From f6bc016f22235ef433dc3ca192b68c05dd1a9993 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 15:27:55 +1000 Subject: [PATCH 12/16] v5.12.27 --- VERSION.txt | 2 +- config/ninja.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION.txt b/VERSION.txt index 7620216932..f5ac77f2d0 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -5.12.26 \ No newline at end of file +5.12.27 \ No newline at end of file diff --git a/config/ninja.php b/config/ninja.php index 282acdac63..4c0508de02 100644 --- a/config/ninja.php +++ b/config/ninja.php @@ -17,7 +17,7 @@ return [ 'require_https' => env('REQUIRE_HTTPS', true), 'app_url' => rtrim(env('APP_URL', ''), '/'), 'app_domain' => env('APP_DOMAIN', 'invoicing.co'), - 'app_version' => env('APP_VERSION', '5.12.26'), + 'app_version' => env('APP_VERSION', '5.12.27'), 'app_tag' => env('APP_TAG', '5.12.26'), 'minimum_client_version' => '5.0.16', 'terms_version' => '1.0.1', From 4eb5582a643376e0b64e3bac980f0d3a4a494933 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 15:49:16 +1000 Subject: [PATCH 13/16] fixes for e_invoice --- app/Jobs/EDocument/MergeEDocument.php | 1 + .../EDocument/Standards/ZugferdEDocument.php | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/Jobs/EDocument/MergeEDocument.php b/app/Jobs/EDocument/MergeEDocument.php index 77881736c9..8c7a3654cc 100644 --- a/app/Jobs/EDocument/MergeEDocument.php +++ b/app/Jobs/EDocument/MergeEDocument.php @@ -31,6 +31,7 @@ class MergeEDocument implements ShouldQueue */ public function handle(): string { + nlog("MergeEDocument:: handle"); $settings_entity = ($this->document instanceof PurchaseOrder) ? $this->document->vendor : $this->document->client; $e_document_type = strlen($settings_entity->getSetting('e_invoice_type')) > 2 ? $settings_entity->getSetting('e_invoice_type') : "XInvoice_3_0"; diff --git a/app/Services/EDocument/Standards/ZugferdEDocument.php b/app/Services/EDocument/Standards/ZugferdEDocument.php index 1a5eac1222..13c4770ca8 100644 --- a/app/Services/EDocument/Standards/ZugferdEDocument.php +++ b/app/Services/EDocument/Standards/ZugferdEDocument.php @@ -92,8 +92,8 @@ class ZugferdEDocument extends AbstractService ->setPaymentTerms() // 3. Then payment terms ->setLineItems() // 4. Then line items ->setCustomSurcharges() // 4a. Surcharges - ->setDocumentSummation() // 5. Finally document summation - ->setAdditionalReferencedDocument(); // 6. Additional referenced document + ->setDocumentSummation(); // 5. Finally document summation + // ->setAdditionalReferencedDocument(); // 6. Additional referenced document return $this; @@ -127,7 +127,17 @@ class ZugferdEDocument extends AbstractService return $this; } - + + /** + * setAdditionalReferencedDocument + * + * circular reference causing the file to never be created. + * PDF => xml => PDF => xml + * + * Need to abstract the insertion of the base64 document into the XML. + * + * @return self + */ private function setAdditionalReferencedDocument(): self { if($this->document->client->getSetting('merge_e_invoice_to_pdf')) { From 2c3ac4aad963e5fb10c81f3427eb25baa2dbb2a6 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 15:50:19 +1000 Subject: [PATCH 14/16] BCMath functions --- app/Utils/BcMath.php | 375 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 app/Utils/BcMath.php diff --git a/app/Utils/BcMath.php b/app/Utils/BcMath.php new file mode 100644 index 0000000000..2b68052c5c --- /dev/null +++ b/app/Utils/BcMath.php @@ -0,0 +1,375 @@ += 0; + } + + /** + * Check if left number is less than right number using bcmath + * + * @param string|float|int $left + * @param string|float|int $right + * @param int|null $scale + * @return bool + */ + public static function lessThan($left, $right, ?int $scale = null): bool + { + return self::comp($left, $right, $scale) === -1; + } + + /** + * Check if left number is less than or equal to right number using bcmath + * + * @param string|float|int $left + * @param string|float|int $right + * @param int|null $scale + * @return bool + */ + public static function lessThanOrEqual($left, $right, ?int $scale = null): bool + { + return self::comp($left, $right, $scale) <= 0; + } + + /** + * Check if a number is zero using bcmath + * + * @param string|float|int $number + * @param int|null $scale + * @return bool + */ + public static function isZero($number, ?int $scale = null): bool + { + return self::equal($number, '0', $scale); + } + + /** + * Check if a number is positive using bcmath + * + * @param string|float|int $number + * @param int|null $scale + * @return bool + */ + public static function isPositive($number, ?int $scale = null): bool + { + return self::greaterThan($number, '0', $scale); + } + + /** + * Check if a number is negative using bcmath + * + * @param string|float|int $number + * @param int|null $scale + * @return bool + */ + public static function isNegative($number, ?int $scale = null): bool + { + return self::lessThan($number, '0', $scale); + } + + /** + * Get the absolute value using bcmath + * + * @param string|float|int $number + * @param int|null $scale + * @return string + */ + public static function abs($number, ?int $scale = null): string + { + $scale = $scale ?? self::DEFAULT_SCALE; + $number = (string)$number; + + if (self::isNegative($number, $scale)) { + return self::mul($number, '-1', $scale); + } + + return $number; + } + + /** + * Calculate percentage using bcmath + * + * @param string|float|int $part + * @param string|float|int $total + * @param int|null $scale + * @return string + */ + public static function percentage($part, $total, ?int $scale = null): string + { + $scale = $scale ?? self::DEFAULT_SCALE; + + if (self::isZero($total, $scale)) { + return '0'; + } + + return self::mul(self::div($part, $total, $scale + 2), '100', $scale); + } + + /** + * Calculate sum of an array of numbers using bcmath + * + * @param array $numbers + * @param int|null $scale + * @return string + */ + public static function sum(array $numbers, ?int $scale = null): string + { + $scale = $scale ?? self::DEFAULT_SCALE; + $result = '0'; + + foreach ($numbers as $number) { + $result = self::add($result, $number, $scale); + } + + return $result; + } + + /** + * Calculate average of an array of numbers using bcmath + * + * @param array $numbers + * @param int|null $scale + * @return string + */ + public static function avg(array $numbers, ?int $scale = null): string + { + $scale = $scale ?? self::DEFAULT_SCALE; + $count = count($numbers); + + if ($count === 0) { + return '0'; + } + + $sum = self::sum($numbers, $scale); + return self::div($sum, (string)$count, $scale); + } + + /** + * Format a number as currency string with proper precision + * + * @param string|float|int $number + * @param int $precision + * @return string + */ + public static function formatCurrency($number, int $precision = self::DEFAULT_SCALE): string + { + $rounded = self::round($number, $precision); + return number_format((float)$rounded, $precision, '.', ''); + } + + /** + * Convert a number to float for compatibility with existing code + * Use this sparingly and only when you need to pass values to functions that expect floats + * + * @param string|float|int $number + * @return float + */ + public static function toFloat($number): float + { + return (float)$number; + } + + /** + * Convert a number to string for consistent handling + * + * @param string|float|int $number + * @return string + */ + public static function toString($number): string + { + return (string)$number; + } +} From 331be9d309c44938077751a22acd04356e114060 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 16:34:01 +1000 Subject: [PATCH 15/16] Patches for admin mailable --- app/Jobs/EDocument/EInvoicePullDocs.php | 31 +++++++++++++++++++++++ app/Services/Email/AdminEmailMailable.php | 2 +- lang/en/texts.php | 5 +++- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/Jobs/EDocument/EInvoicePullDocs.php b/app/Jobs/EDocument/EInvoicePullDocs.php index 26c2808dbe..2afaab0359 100644 --- a/app/Jobs/EDocument/EInvoicePullDocs.php +++ b/app/Jobs/EDocument/EInvoicePullDocs.php @@ -16,8 +16,12 @@ use App\Utils\Ninja; use App\Models\Account; use App\Models\Company; use App\Utils\TempFile; +use App\Services\Email\Email; use Illuminate\Bus\Queueable; +use App\Services\Email\EmailObject; +use Illuminate\Support\Facades\App; use App\Utils\Traits\SavesDocuments; +use Illuminate\Mail\Mailables\Address; use Illuminate\Queue\SerializesModels; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Contracts\Queue\ShouldQueue; @@ -36,6 +40,8 @@ class EInvoicePullDocs implements ShouldQueue public $tries = 1; + private int $einvoice_received_count = 0; + public function __construct() { } @@ -64,6 +70,8 @@ class EInvoicePullDocs implements ShouldQueue }) ->each(function ($company) { + $this->einvoice_received_count = 0; + $response = \Illuminate\Support\Facades\Http::baseUrl(config('ninja.hosted_ninja_url')) ->withHeaders([ 'Content-Type' => 'application/json', @@ -86,6 +94,24 @@ class EInvoicePullDocs implements ShouldQueue nlog($response->body()); } + + + if($this->einvoice_received_count > 0) { + App::setLocale($company->getLocale()); + + $mo = new EmailObject(); + $mo->subject = ctrans('texts.einvoice_received_subject'); + $mo->body = ctrans('texts.einvoice_received_body', ['count' => $this->einvoice_received_count]); + $mo->text_body = ctrans('texts.einvoice_received_body', ['count' => $this->einvoice_received_count]); + $mo->company_key = $company->company_key; + $mo->html_template = 'email.template.admin'; + $mo->to = [new Address($company->owner()->email, $company->owner()->present()->name())]; + // $mo->email_template_body = 'einvoice_received_body'; + // $mo->email_template_subject = 'einvoice_received_subject'; + + Email::dispatch($mo, $company); + } + }); }); @@ -96,6 +122,8 @@ class EInvoicePullDocs implements ShouldQueue $storecove = new Storecove(); + $mail_payload = []; + foreach ($received_documents as $document) { nlog($document); $storecove_invoice = $storecove->expense->getStorecoveInvoice(json_encode($document['document']['invoice'])); @@ -125,6 +153,7 @@ class EInvoicePullDocs implements ShouldQueue } + $this->einvoice_received_count++; } @@ -145,6 +174,8 @@ class EInvoicePullDocs implements ShouldQueue if ($response->successful()) { } + + } public function failed(\Throwable $exception) diff --git a/app/Services/Email/AdminEmailMailable.php b/app/Services/Email/AdminEmailMailable.php index 94842e495c..b20176309c 100644 --- a/app/Services/Email/AdminEmailMailable.php +++ b/app/Services/Email/AdminEmailMailable.php @@ -62,7 +62,7 @@ class AdminEmailMailable extends Mailable text: 'email.admin.generic_text', with: [ 'title' => $this->email_object->subject, - 'message' => $this->email_object->body, + 'content' => $this->email_object->body, 'url' => $this->email_object->url ?? null, 'button' => $this->email_object->button ?? null, 'signature' => $this->email_object->company->owner()->signature, diff --git a/lang/en/texts.php b/lang/en/texts.php index 582a6abb28..733146f182 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -5631,7 +5631,10 @@ $lang = array( 'replaced' => 'Replaced', 'ses_from_address' => 'SES From Address', 'ses_from_address_help' => 'The Sending Email Address, must be verified in AWS', - 'unauthorized_action' => 'You are not authorized to perform this action', + 'unauthorized_action' => 'You are not authorized to perform this action', + 'einvoice_received_subject' => 'E-Invoice/s Received', + 'einvoice_received_body' => 'You have received :count new E-Invoice/s.

Login to view.', + ); return $lang; From 3319fd792037250661538a49abd566c9b6b7da32 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Sat, 6 Sep 2025 16:36:40 +1000 Subject: [PATCH 16/16] Minor cleanup --- app/Services/Email/AdminEmailMailable.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/Services/Email/AdminEmailMailable.php b/app/Services/Email/AdminEmailMailable.php index b20176309c..8a1370f5db 100644 --- a/app/Services/Email/AdminEmailMailable.php +++ b/app/Services/Email/AdminEmailMailable.php @@ -56,7 +56,6 @@ class AdminEmailMailable extends Mailable */ public function content() { - return new Content( view: 'email.admin.generic', text: 'email.admin.generic_text',