Add payment scheduler for invoices

This commit is contained in:
David Bomba 2025-08-01 12:43:05 +10:00
parent 760e624ff6
commit f2f08e22e6
7 changed files with 289 additions and 27 deletions

View File

@ -12,40 +12,43 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Events\Invoice\InvoiceWasCreated; use App\Utils\Ninja;
use App\Events\Invoice\InvoiceWasUpdated; use App\Models\Quote;
use App\Factory\CloneInvoiceFactory; use App\Models\Account;
use App\Factory\CloneInvoiceToQuoteFactory; use App\Models\Invoice;
use App\Jobs\Cron\AutoBill;
use Illuminate\Http\Response;
use App\Factory\InvoiceFactory; use App\Factory\InvoiceFactory;
use App\Filters\InvoiceFilters; 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\BulkInvoiceRequest;
use App\Http\Requests\Invoice\CreateInvoiceRequest;
use App\Http\Requests\Invoice\DestroyInvoiceRequest;
use App\Http\Requests\Invoice\EditInvoiceRequest; use App\Http\Requests\Invoice\EditInvoiceRequest;
use App\Http\Requests\Invoice\ShowInvoiceRequest; use App\Http\Requests\Invoice\ShowInvoiceRequest;
use App\Http\Requests\Invoice\StoreInvoiceRequest; 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\UpdateInvoiceRequest;
use App\Http\Requests\Invoice\UpdateReminderRequest;
use App\Http\Requests\Invoice\UploadInvoiceRequest; use App\Http\Requests\Invoice\UploadInvoiceRequest;
use App\Jobs\Cron\AutoBill; use App\Http\Requests\Invoice\DestroyInvoiceRequest;
use App\Jobs\Invoice\BulkInvoiceJob; use App\Http\Requests\Invoice\UpdateReminderRequest;
use App\Jobs\Invoice\UpdateReminders; use App\Http\Requests\TaskScheduler\PaymentScheduleRequest;
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;
/** /**
* Class InvoiceController. * Class InvoiceController.
@ -1065,4 +1068,14 @@ class InvoiceController extends BaseController
return response()->json(['message' => 'Updating reminders'], 200); 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());
}
} }

View File

@ -64,7 +64,6 @@ class TaskSchedulerController extends BaseController
return $this->itemResponse($scheduler); return $this->itemResponse($scheduler);
} }
public function show(ShowSchedulerRequest $request, Scheduler $scheduler) public function show(ShowSchedulerRequest $request, Scheduler $scheduler)
{ {
return $this->itemResponse($scheduler); return $this->itemResponse($scheduler);

View File

@ -0,0 +1,145 @@
<?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\Http\Requests\TaskScheduler;
use App\Http\Requests\Request;
use App\Models\RecurringInvoice;
use App\Models\RecurringQuote;
use Illuminate\Support\Carbon;
class PaymentScheduleRequest extends Request
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize(): bool
{
return auth()->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),
};
}
}

View File

@ -30,6 +30,7 @@ use App\Helpers\Invoice\InvoiceSumInclusive;
use App\Utils\Traits\Invoice\ActionsInvoice; use App\Utils\Traits\Invoice\ActionsInvoice;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use App\Events\Invoice\InvoiceReminderWasEmailed; use App\Events\Invoice\InvoiceReminderWasEmailed;
use App\Jobs\Ninja\TaskScheduler;
use App\Utils\Number; use App\Utils\Number;
/** /**
@ -832,4 +833,24 @@ class Invoice extends BaseModel
return $reminder_schedule; 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();
}
} }

View File

@ -183,6 +183,11 @@ class InvoiceTransformer extends EntityTransformer
if (request()->has('is_locked') && request()->query('is_locked') == 'true') { if (request()->has('is_locked') && request()->query('is_locked') == 'true') {
$data['is_locked'] = (bool) $invoice->isLocked(); $data['is_locked'] = (bool) $invoice->isLocked();
} }
if (request()->has('show_schedule') && request()->query('show_schedule') == 'true') {
$data['schedule'] = (array) $invoice->paymentSchedule();
}
return $data; return $data;

View File

@ -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}/delivery_note', [InvoiceController::class, 'deliveryNote'])->name('invoices.delivery_note');
Route::get('invoices/{invoice}/{action}', [InvoiceController::class, 'action'])->name('invoices.action'); Route::get('invoices/{invoice}/{action}', [InvoiceController::class, 'action'])->name('invoices.action');
Route::put('invoices/{invoice}/upload', [InvoiceController::class, 'upload'])->name('invoices.upload'); 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', [InvoiceController::class, 'downloadPdf'])->name('invoices.downloadPdf');
Route::get('invoice/{invitation_key}/download_e_invoice', [InvoiceController::class, 'downloadEInvoice'])->name('invoices.downloadEInvoice'); Route::get('invoice/{invitation_key}/download_e_invoice', [InvoiceController::class, 'downloadEInvoice'])->name('invoices.downloadEInvoice');
Route::post('invoices/bulk', [InvoiceController::class, 'bulk'])->name('invoices.bulk'); Route::post('invoices/bulk', [InvoiceController::class, 'bulk'])->name('invoices.bulk');

View File

@ -15,6 +15,7 @@ use Carbon\Carbon;
use Tests\TestCase; use Tests\TestCase;
use App\Models\Task; use App\Models\Task;
use App\Models\Client; use App\Models\Client;
use App\Models\Invoice;
use App\Models\Scheduler; use App\Models\Scheduler;
use Tests\MockAccountData; use Tests\MockAccountData;
use App\Utils\Traits\MakesHash; use App\Utils\Traits\MakesHash;
@ -29,6 +30,7 @@ use App\Services\Scheduler\EmailStatementService;
use Illuminate\Routing\Middleware\ThrottleRequests; use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use App\Services\Scheduler\InvoiceOutstandingTasksService; use App\Services\Scheduler\InvoiceOutstandingTasksService;
use App\Http\Requests\TaskScheduler\PaymentScheduleRequest;
/** /**
* *
@ -61,6 +63,82 @@ class SchedulerTest extends TestCase
// $this->withoutExceptionHandling(); // $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() public function testPaymentSchedule()
{ {
$data = [ $data = [