Add payment scheduler for invoices
This commit is contained in:
parent
760e624ff6
commit
f2f08e22e6
|
|
@ -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());
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,6 @@ class TaskSchedulerController extends BaseController
|
|||
return $this->itemResponse($scheduler);
|
||||
}
|
||||
|
||||
|
||||
public function show(ShowSchedulerRequest $request, Scheduler $scheduler)
|
||||
{
|
||||
return $this->itemResponse($scheduler);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in New Issue