From f2f08e22e600a89c2270ffc8bd2058c9ae7a6bf5 Mon Sep 17 00:00:00 2001 From: David Bomba Date: Fri, 1 Aug 2025 12:43:05 +1000 Subject: [PATCH] Add payment scheduler for invoices --- app/Http/Controllers/InvoiceController.php | 65 ++++---- .../Controllers/TaskSchedulerController.php | 1 - .../TaskScheduler/PaymentScheduleRequest.php | 145 ++++++++++++++++++ app/Models/Invoice.php | 21 +++ app/Transformers/InvoiceTransformer.php | 5 + routes/api.php | 1 + tests/Feature/Scheduler/SchedulerTest.php | 78 ++++++++++ 7 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 app/Http/Requests/TaskScheduler/PaymentScheduleRequest.php diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 8072c36a49..70df6d9fc6 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -12,40 +12,43 @@ namespace App\Http\Controllers; -use App\Events\Invoice\InvoiceWasCreated; -use App\Events\Invoice\InvoiceWasUpdated; -use App\Factory\CloneInvoiceFactory; -use App\Factory\CloneInvoiceToQuoteFactory; +use App\Utils\Ninja; +use App\Models\Quote; +use App\Models\Account; +use App\Models\Invoice; +use App\Jobs\Cron\AutoBill; +use Illuminate\Http\Response; use App\Factory\InvoiceFactory; use App\Filters\InvoiceFilters; -use App\Http\Requests\Invoice\ActionInvoiceRequest; +use App\Utils\Traits\MakesHash; +use App\Factory\SchedulerFactory; +use App\Jobs\Invoice\ZipInvoices; +use App\Services\PdfMaker\PdfMerge; +use Illuminate\Support\Facades\App; +use App\Factory\CloneInvoiceFactory; +use App\Jobs\Invoice\BulkInvoiceJob; +use App\Utils\Traits\SavesDocuments; +use App\Jobs\Invoice\UpdateReminders; +use App\Transformers\QuoteTransformer; +use App\Repositories\InvoiceRepository; +use Illuminate\Support\Facades\Storage; +use App\Transformers\InvoiceTransformer; +use App\Events\Invoice\InvoiceWasCreated; +use App\Events\Invoice\InvoiceWasUpdated; +use App\Repositories\SchedulerRepository; +use App\Services\Template\TemplateAction; +use App\Factory\CloneInvoiceToQuoteFactory; use App\Http\Requests\Invoice\BulkInvoiceRequest; -use App\Http\Requests\Invoice\CreateInvoiceRequest; -use App\Http\Requests\Invoice\DestroyInvoiceRequest; use App\Http\Requests\Invoice\EditInvoiceRequest; use App\Http\Requests\Invoice\ShowInvoiceRequest; use App\Http\Requests\Invoice\StoreInvoiceRequest; +use App\Http\Requests\Invoice\ActionInvoiceRequest; +use App\Http\Requests\Invoice\CreateInvoiceRequest; use App\Http\Requests\Invoice\UpdateInvoiceRequest; -use App\Http\Requests\Invoice\UpdateReminderRequest; use App\Http\Requests\Invoice\UploadInvoiceRequest; -use App\Jobs\Cron\AutoBill; -use App\Jobs\Invoice\BulkInvoiceJob; -use App\Jobs\Invoice\UpdateReminders; -use App\Jobs\Invoice\ZipInvoices; -use App\Models\Account; -use App\Models\Invoice; -use App\Models\Quote; -use App\Repositories\InvoiceRepository; -use App\Services\PdfMaker\PdfMerge; -use App\Services\Template\TemplateAction; -use App\Transformers\InvoiceTransformer; -use App\Transformers\QuoteTransformer; -use App\Utils\Ninja; -use App\Utils\Traits\MakesHash; -use App\Utils\Traits\SavesDocuments; -use Illuminate\Http\Response; -use Illuminate\Support\Facades\App; -use Illuminate\Support\Facades\Storage; +use App\Http\Requests\Invoice\DestroyInvoiceRequest; +use App\Http\Requests\Invoice\UpdateReminderRequest; +use App\Http\Requests\TaskScheduler\PaymentScheduleRequest; /** * Class InvoiceController. @@ -1065,4 +1068,14 @@ class InvoiceController extends BaseController return response()->json(['message' => 'Updating reminders'], 200); } + + public function paymentSchedule(PaymentScheduleRequest $request, Invoice $invoice) + { + $repo = new SchedulerRepository(); + + $repo->save($request->all(), SchedulerFactory::create($invoice->company_id, auth()->user()->id)); + + return $this->itemResponse($invoice->fresh()); + + } } diff --git a/app/Http/Controllers/TaskSchedulerController.php b/app/Http/Controllers/TaskSchedulerController.php index a517b8781a..ea1b1f9d58 100644 --- a/app/Http/Controllers/TaskSchedulerController.php +++ b/app/Http/Controllers/TaskSchedulerController.php @@ -64,7 +64,6 @@ class TaskSchedulerController extends BaseController return $this->itemResponse($scheduler); } - public function show(ShowSchedulerRequest $request, Scheduler $scheduler) { return $this->itemResponse($scheduler); diff --git a/app/Http/Requests/TaskScheduler/PaymentScheduleRequest.php b/app/Http/Requests/TaskScheduler/PaymentScheduleRequest.php new file mode 100644 index 0000000000..05e78e9a5c --- /dev/null +++ b/app/Http/Requests/TaskScheduler/PaymentScheduleRequest.php @@ -0,0 +1,145 @@ +user()->can('edit', $this->invoice); + } + + public function rules() + { + return [ + 'next_run' => 'required|date:Y-m-d', + 'frequency_id' => 'sometimes|integer|required_with:remaining_cycles', + 'remaining_cycles' => 'sometimes|integer|required_with:frequency_id', + 'parameters' => 'bail|array', + 'parameters.schedule' => 'sometimes|array|required_without:frequency_id,remaining_cycles', + 'parameters.schedule.*.id' => 'required|integer', + 'parameters.schedule.*.date' => 'required|date:Y-m-d', + 'parameters.schedule.*.amount' => 'required|numeric', + 'parameters.schedule.*.is_amount' => 'required|boolean', + 'parameters.invoice_id' => 'required|string', + 'parameters.auto_bill' => 'required|boolean', + ]; + } + + public function prepareForValidation() + { + $input = $this->all(); + + $input['parameters']['invoice_id'] = $this->invoice->hashed_id; + $input['template'] = 'payment_schedule'; + $input['name'] = "Payment Schedule for Invoice #{$this->invoice->number}"; + $input['is_paused'] = false; + $input['parameters']['auto_bill'] = (bool) isset($input['parameters']['auto_bill']) ? $input['parameters']['auto_bill'] : false; + + if (isset($input['schedule']) && is_array($input['schedule']) && count($input['schedule']) > 0) { + $schedule_map = collect($input['schedule'])->map(function ($schedule, $key) { + return [ + 'id' => $key, + 'date' => $schedule['date'], + 'amount' => $schedule['amount'], + 'is_amount' => $schedule['is_amount'], + ]; + }); + + $first_map = $schedule_map->first(); + + if ($first_map['is_amount'] && floatval($schedule_map->sum('amount')) != floatval($this->invoice->amount)) { + $this->errors()->add('schedule', 'The total amount of the schedule does not match the invoice amount.'); + } + elseif(!$first_map['is_amount'] && floatval($schedule_map->sum('amount')) != floatval(100)) { + $this->errors()->add('schedule', 'The total amount of the schedule does not match 100%.'); + } + else{ + $input['parameters']['schedule'] = $schedule_map->toArray(); + } + + } + + if (isset($input['frequency_id']) && isset($input['remaining_cycles'])) { + $input['parameters']['schedule'] = $this->generateSchedule($input['frequency_id'], $input['remaining_cycles']); + } + + $input['next_run'] = $input['parameters']['schedule'][0]['date']; + + $this->replace($input); + } + + private function generateSchedule($frequency_id, $remaining_cycles) + { + if(!$this->invoice->due_date) { + $due_date = Carbon::parse($this->invoice->date)->addDays((int)$this->invoice->client->getSetting('payment_terms')); + } else { + $due_date = Carbon::parse($this->invoice->due_date); + } + + $amount = round($this->invoice->amount / $remaining_cycles, 2); + + $delta = round($amount * $remaining_cycles, 2); + $adjustment = 0; + + if(floatval($delta) != floatval($this->invoice->amount)) { + $adjustment = round(floatval($this->invoice->amount) - floatval($delta), 2); //adjustment to make the total amount equal to the invoice amount + } + + $schedule = []; + + for ($i = 0; $i < $remaining_cycles; $i++) { + $schedule[] = [ + 'id' => $i+1, + 'date' => $this->generateScheduleByFrequency($frequency_id, $due_date)->format('Y-m-d'), + 'amount' => $amount, + 'is_amount' => true, + ]; + } + + if($adjustment > 0) { + $schedule[$remaining_cycles-1]['amount'] += $adjustment; + } + + return $schedule; + } + + private function generateScheduleByFrequency(int $frequency_id, Carbon $date) + { + + return match($frequency_id) { + RecurringInvoice::FREQUENCY_DAILY => $date->startOfDay()->addDay(), + RecurringInvoice::FREQUENCY_WEEKLY => $date->startOfDay()->addWeek(), + RecurringInvoice::FREQUENCY_TWO_WEEKS => $date->startOfDay()->addWeeks(2), + RecurringInvoice::FREQUENCY_FOUR_WEEKS => $date->startOfDay()->addWeeks(4), + RecurringInvoice::FREQUENCY_MONTHLY => $date->startOfDay()->addMonthNoOverflow(), + RecurringInvoice::FREQUENCY_TWO_MONTHS => $date->startOfDay()->addMonthsNoOverflow(2), + RecurringInvoice::FREQUENCY_THREE_MONTHS => $date->startOfDay()->addMonthsNoOverflow(3), + RecurringInvoice::FREQUENCY_FOUR_MONTHS => $date->startOfDay()->addMonthsNoOverflow(4), + RecurringInvoice::FREQUENCY_SIX_MONTHS => $date->startOfDay()->addMonthsNoOverflow(6), + RecurringInvoice::FREQUENCY_ANNUALLY => $date->startOfDay()->addYear(), + RecurringInvoice::FREQUENCY_TWO_YEARS => $date->startOfDay()->addYears(2), + RecurringInvoice::FREQUENCY_THREE_YEARS => $date->startOfDay()->addYears(3), + }; + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index ca43a26a4c..75c2311477 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -30,6 +30,7 @@ use App\Helpers\Invoice\InvoiceSumInclusive; use App\Utils\Traits\Invoice\ActionsInvoice; use Illuminate\Database\Eloquent\SoftDeletes; use App\Events\Invoice\InvoiceReminderWasEmailed; +use App\Jobs\Ninja\TaskScheduler; use App\Utils\Number; /** @@ -832,4 +833,24 @@ class Invoice extends BaseModel return $reminder_schedule; } + + public function paymentSchedule(): array + { + + $schedule = \App\Models\Scheduler::where('company_id', $this->company_id) + ->where('template', 'payment_schedule') + ->where('parameters->invoice_id', $this->hashed_id) + ->first(); + + if (! $schedule) { + return []; + } + + return collect($schedule->parameters['schedule'])->map(function ($item) { + return [ + 'date' => $item['date'], + 'amount' => $item['is_amount'] ? \App\Utils\Number::formatMoney($item['amount'], $this->client) : $this->amount ." %", + ]; + })->toArray(); + } } diff --git a/app/Transformers/InvoiceTransformer.php b/app/Transformers/InvoiceTransformer.php index c1136f04d4..94c44237c4 100644 --- a/app/Transformers/InvoiceTransformer.php +++ b/app/Transformers/InvoiceTransformer.php @@ -183,6 +183,11 @@ class InvoiceTransformer extends EntityTransformer if (request()->has('is_locked') && request()->query('is_locked') == 'true') { $data['is_locked'] = (bool) $invoice->isLocked(); } + + if (request()->has('show_schedule') && request()->query('show_schedule') == 'true') { + $data['schedule'] = (array) $invoice->paymentSchedule(); + } + return $data; diff --git a/routes/api.php b/routes/api.php index 3d061b2f88..f883defc14 100644 --- a/routes/api.php +++ b/routes/api.php @@ -276,6 +276,7 @@ Route::group(['middleware' => ['throttle:api', 'token_auth', 'valid_json','local Route::get('invoices/{invoice}/delivery_note', [InvoiceController::class, 'deliveryNote'])->name('invoices.delivery_note'); Route::get('invoices/{invoice}/{action}', [InvoiceController::class, 'action'])->name('invoices.action'); Route::put('invoices/{invoice}/upload', [InvoiceController::class, 'upload'])->name('invoices.upload'); + Route::post('invoices/{invoice}/payment_schedule', [InvoiceController::class, 'paymentSchedule'])->name('invoices.payment_schedule'); Route::get('invoice/{invitation_key}/download', [InvoiceController::class, 'downloadPdf'])->name('invoices.downloadPdf'); Route::get('invoice/{invitation_key}/download_e_invoice', [InvoiceController::class, 'downloadEInvoice'])->name('invoices.downloadEInvoice'); Route::post('invoices/bulk', [InvoiceController::class, 'bulk'])->name('invoices.bulk'); diff --git a/tests/Feature/Scheduler/SchedulerTest.php b/tests/Feature/Scheduler/SchedulerTest.php index 18bec8e653..d5c1288739 100644 --- a/tests/Feature/Scheduler/SchedulerTest.php +++ b/tests/Feature/Scheduler/SchedulerTest.php @@ -15,6 +15,7 @@ use Carbon\Carbon; use Tests\TestCase; use App\Models\Task; use App\Models\Client; +use App\Models\Invoice; use App\Models\Scheduler; use Tests\MockAccountData; use App\Utils\Traits\MakesHash; @@ -29,6 +30,7 @@ use App\Services\Scheduler\EmailStatementService; use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Foundation\Testing\DatabaseTransactions; use App\Services\Scheduler\InvoiceOutstandingTasksService; +use App\Http\Requests\TaskScheduler\PaymentScheduleRequest; /** * @@ -61,6 +63,82 @@ class SchedulerTest extends TestCase // $this->withoutExceptionHandling(); } + + public function testPaymentScheduleRequestValidation() + { + $invoice = Invoice::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, + 'date' => now()->format('Y-m-d'), + 'due_date' => now()->addDays(30)->format('Y-m-d'), + 'amount' => 300.00, + 'balance' => 300.00, + ]); + + $invoice->service()->markSent()->save(); + + $data = [ + 'schedule' => [ + [ + 'id' => 1, + 'date' => now()->format('Y-m-d'), + 'amount' => 100.00, + 'is_amount' => true, + ], + [ + 'id' => 2, + 'date' => now()->addDays(30)->format('Y-m-d'), + 'amount' => 200.00, + 'is_amount' => true, + ] + ], + 'auto_bill' => true, + ]; + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson('/api/v1/invoices/'.$invoice->hashed_id.'/payment_schedule', $data); + + $response->assertStatus(200); + + } + + public function testPaymentScheduleRequestWithFrequency() + { + $invoice = Invoice::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, + 'date' => now()->format('Y-m-d'), + 'due_date' => now()->addDays(30)->format('Y-m-d'), + 'amount' => 300.00, + 'balance' => 300.00, + ]); + + $invoice->service()->markSent()->save(); + + + $data = [ + 'frequency_id' => 5, // Monthly + 'remaining_cycles' => 3, + 'auto_bill' => false, + ]; + + + $response = $this->withHeaders([ + 'X-API-SECRET' => config('ninja.api_secret'), + 'X-API-TOKEN' => $this->token, + ])->postJson('/api/v1/invoices/'.$invoice->hashed_id.'/payment_schedule?show_schedule=true', $data); + + $response->assertStatus(200); + nlog($response->json()); + + } + + + public function testPaymentSchedule() { $data = [