mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2024-11-10 05:02:36 +01:00
Allow fine grained payments (#3110)
* Allow payment amounts to be partial per invoice paid * edge case tests for payments * Allow per invoice payment amounts and allow direct payments which simply credit a clients credit_balance * Fixes
This commit is contained in:
parent
a7f939c6b9
commit
25514b43cf
@ -43,6 +43,7 @@
|
||||
* @OA\Property(property="is_deleted", type="boolean", example=true, description="________"),
|
||||
* @OA\Property(property="balance", type="number", format="float", example="10.00", description="________"),
|
||||
* @OA\Property(property="paid_to_date", type="number", format="float", example="10.00", description="________"),
|
||||
* @OA\Property(property="credit_balance", type="number", format="float", example="10.00", description="An amount which is available to the client for future use."),
|
||||
* @OA\Property(property="last_login", type="number", format="integer", example="134341234234", description="Timestamp"),
|
||||
* @OA\Property(property="created_at", type="number", format="integer", example="134341234234", description="Timestamp"),
|
||||
* @OA\Property(property="updated_at", type="number", format="integer", example="134341234234", description="Timestamp"),
|
||||
|
@ -33,15 +33,38 @@ class StorePaymentRequest extends Request
|
||||
|
||||
}
|
||||
|
||||
protected function prepareForValidation()
|
||||
{
|
||||
|
||||
$input = $this->all();
|
||||
|
||||
if(isset($input['client_id']))
|
||||
$input['client_id'] = $this->decodePrimaryKey($input['client_id']);
|
||||
|
||||
if(isset($input['invoices'])){
|
||||
|
||||
foreach($input['invoices'] as $key => $value)
|
||||
{
|
||||
$input['invoices'][$key]['id'] = $this->decodePrimaryKey($value['id']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
if(is_array($input['invoices']) === false)
|
||||
$input['invoices'] = null;
|
||||
|
||||
$this->replace($input);
|
||||
|
||||
}
|
||||
|
||||
public function rules()
|
||||
{
|
||||
$this->sanitize();
|
||||
|
||||
|
||||
$rules = [
|
||||
'amount' => 'numeric|required',
|
||||
'payment_date' => 'required',
|
||||
'client_id' => 'required',
|
||||
'invoices' => 'required',
|
||||
'invoices' => new ValidPayableInvoicesRule(),
|
||||
];
|
||||
|
||||
@ -50,25 +73,4 @@ class StorePaymentRequest extends Request
|
||||
}
|
||||
|
||||
|
||||
public function sanitize()
|
||||
{
|
||||
$input = $this->all();
|
||||
|
||||
if(isset($input['client_id']))
|
||||
$input['client_id'] = $this->decodePrimaryKey($input['client_id']);
|
||||
|
||||
if(isset($input['invoices']))
|
||||
$input['invoices'] = $this->transformKeys(explode(",", $input['invoices']));
|
||||
|
||||
if(is_array($input['invoices']) === false)
|
||||
$input['invoices'] = null;
|
||||
|
||||
|
||||
$this->replace($input);
|
||||
|
||||
return $this->all();
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -28,18 +28,23 @@ class ValidPayableInvoicesRule implements Rule
|
||||
* @param mixed $value
|
||||
* @return bool
|
||||
*/
|
||||
|
||||
private $error_msg;
|
||||
|
||||
public function passes($attribute, $value)
|
||||
{
|
||||
/*If no invoices has been sent, then we apply the payment to the client account*/
|
||||
$invoices = [];
|
||||
|
||||
$invoices = Invoice::whereIn('id', $this->transformKeys(explode(",",$value)))->get();
|
||||
|
||||
if(!$invoices || $invoices->count() == 0)
|
||||
return false;
|
||||
if(is_array($value))
|
||||
$invoices = Invoice::whereIn('id', array_column($value,'id'))->company()->get();
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
|
||||
if(! $invoice->isPayable())
|
||||
return false;
|
||||
if(! $invoice->isPayable()) {
|
||||
$this->error_msg = "One or more of these invoices have been paid";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -50,7 +55,7 @@ class ValidPayableInvoicesRule implements Rule
|
||||
*/
|
||||
public function message()
|
||||
{
|
||||
return "One or more of these invoices have been paid";
|
||||
return $this->error_msg;
|
||||
}
|
||||
|
||||
}
|
||||
|
62
app/Jobs/Invoice/ApplyClientPayment.php
Normal file
62
app/Jobs/Invoice/ApplyClientPayment.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com)
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2019. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://opensource.org/licenses/AAL
|
||||
*/
|
||||
|
||||
namespace App\Jobs\Invoice;
|
||||
|
||||
use App\Events\Payment\PaymentWasCreated;
|
||||
use App\Factory\PaymentFactory;
|
||||
use App\Jobs\Client\UpdateClientBalance;
|
||||
use App\Jobs\Client\UpdateClientPaidToDate;
|
||||
use App\Jobs\Company\UpdateCompanyLedgerWithPayment;
|
||||
use App\Jobs\Invoice\ApplyPaymentToInvoice;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ApplyClientPayment implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $payment;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Payment $payment)
|
||||
{
|
||||
|
||||
$this->payment = $payment;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
|
||||
$client = $this->payment->client;
|
||||
$client->credit_balance += $this->payment->amount;
|
||||
$client->save();
|
||||
|
||||
}
|
||||
|
||||
}
|
114
app/Jobs/Invoice/ApplyInvoicePayment.php
Normal file
114
app/Jobs/Invoice/ApplyInvoicePayment.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com)
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2019. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://opensource.org/licenses/AAL
|
||||
*/
|
||||
|
||||
namespace App\Jobs\Invoice;
|
||||
|
||||
use App\Events\Payment\PaymentWasCreated;
|
||||
use App\Factory\PaymentFactory;
|
||||
use App\Jobs\Client\UpdateClientBalance;
|
||||
use App\Jobs\Client\UpdateClientPaidToDate;
|
||||
use App\Jobs\Company\UpdateCompanyLedgerWithPayment;
|
||||
use App\Jobs\Invoice\ApplyPaymentToInvoice;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Repositories\InvoiceRepository;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ApplyInvoicePayment implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public $invoice;
|
||||
|
||||
public $payment;
|
||||
|
||||
public $amount;
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Invoice $invoice, Payment $payment, float $amount)
|
||||
{
|
||||
|
||||
$this->invoice = $invoice;
|
||||
$this->payment = $payment;
|
||||
$this->amount = $amount;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
|
||||
UpdateCompanyLedgerWithPayment::dispatchNow($this->payment, ($this->amount*-1));
|
||||
UpdateClientBalance::dispatchNow($this->payment->client, $this->amount*-1);
|
||||
UpdateClientPaidToDate::dispatchNow($this->payment->client, $this->amount);
|
||||
|
||||
/* Update Pivot Record amount */
|
||||
$this->payment->invoices->each(function ($inv){
|
||||
|
||||
if($inv->id == $this->invoice->id){
|
||||
$inv->pivot->amount = $this->amount;
|
||||
$inv->pivot->save();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
if($this->invoice->hasPartial())
|
||||
{
|
||||
//is partial and amount is exactly the partial amount
|
||||
if($this->invoice->partial == $this->amount)
|
||||
{
|
||||
$this->invoice->clearPartial();
|
||||
$this->invoice->setDueDate();
|
||||
$this->invoice->setStatus(Invoice::STATUS_PARTIAL);
|
||||
$this->invoice->updateBalance($this->amount*-1);
|
||||
}
|
||||
elseif($this->invoice->partial > 0 && $this->invoice->partial > $this->amount) //partial amount exists, but the amount is less than the partial amount
|
||||
{
|
||||
\Log::error("partial > amount");
|
||||
$this->invoice->partial -= $this->amount;
|
||||
$this->invoice->updateBalance($this->amount*-1);
|
||||
}
|
||||
elseif($this->invoice->partial > 0 && $this->invoice->partial < $this->amount) //partial exists and the amount paid is GREATER than the partial amount
|
||||
{
|
||||
\Log::error("partial < amount");
|
||||
$this->invoice->clearPartial();
|
||||
$this->invoice->setDueDate();
|
||||
$this->invoice->setStatus(Invoice::STATUS_PARTIAL);
|
||||
$this->invoice->updateBalance($this->amount*-1);
|
||||
}
|
||||
|
||||
}
|
||||
elseif($this->invoice->amount == $this->invoice->balance) //total invoice paid.
|
||||
{
|
||||
\Log::error("balance == amount");
|
||||
|
||||
$this->invoice->clearPartial();
|
||||
$this->invoice->setDueDate();
|
||||
$this->invoice->setStatus(Invoice::STATUS_PAID);
|
||||
$this->invoice->updateBalance($this->amount*-1);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
@ -370,6 +370,7 @@ class Invoice extends BaseModel
|
||||
|
||||
$balance_adjustment = floatval($balance_adjustment);
|
||||
|
||||
\Log::error("adjusting balance from ". $this->balance. " to ". ($this->balance + $balance_adjustment));
|
||||
$this->balance = $this->balance + $balance_adjustment;
|
||||
|
||||
if($this->balance == 0) {
|
||||
@ -385,8 +386,7 @@ class Invoice extends BaseModel
|
||||
|
||||
public function setDueDate()
|
||||
{
|
||||
|
||||
$this->due_date = Carbon::now()->addDays(PaymentTerm::find($this->company->settings->payment_terms_id)->num_days);
|
||||
$this->due_date = Carbon::now()->addDays($this->client->getSetting('payment_terms'));
|
||||
$this->save();
|
||||
}
|
||||
|
||||
|
@ -14,6 +14,8 @@ namespace App\Repositories;
|
||||
use App\Events\Payment\PaymentWasCreated;
|
||||
use App\Jobs\Company\UpdateCompanyLedgerWithPayment;
|
||||
use App\Jobs\Invoice\UpdateInvoicePayment;
|
||||
use App\Jobs\Invoice\ApplyInvoicePayment;
|
||||
use App\Jobs\Invoice\ApplyClientPayment;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Http\Request;
|
||||
@ -39,17 +41,31 @@ class PaymentRepository extends BaseRepository
|
||||
if($request->input('invoices'))
|
||||
{
|
||||
|
||||
$invoices = Invoice::whereIn('id', $request->input('invoices'))->get();
|
||||
$invoices = Invoice::whereIn('id', array_column($request->input('invoices'),'id'))->company()->get();
|
||||
|
||||
$payment->invoices()->saveMany($invoices);
|
||||
|
||||
foreach($request->input('invoices') as $paid_invoice)
|
||||
{
|
||||
|
||||
$invoice = Invoice::whereId($paid_invoice['id'])->company()->first();
|
||||
|
||||
if($invoice)
|
||||
ApplyInvoicePayment::dispatchNow($invoice, $payment, $paid_invoice['amount']);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
//paid is made, but not to any invoice, therefore we are applying the payment to the clients credit
|
||||
ApplyClientPayment::dispatchNow($payment);
|
||||
}
|
||||
|
||||
event(new PaymentWasCreated($payment));
|
||||
|
||||
UpdateInvoicePayment::dispatchNow($payment);
|
||||
//UpdateInvoicePayment::dispatchNow($payment);
|
||||
|
||||
return $payment;
|
||||
return $payment->fresh();
|
||||
|
||||
}
|
||||
|
||||
|
@ -86,6 +86,7 @@ class ClientTransformer extends EntityTransformer
|
||||
'balance' => (float) $client->balance,
|
||||
'group_settings_id' => isset($client->group_settings_id) ? (string)$this->encodePrimaryKey($client->group_settings_id) : '',
|
||||
'paid_to_date' => (float) $client->paid_to_date,
|
||||
'credit_balance' => (float) $client->credit_balance,
|
||||
'last_login' => (int)$client->last_login,
|
||||
// 'currency_id' => (string)$client->currency_id,
|
||||
'address1' => $client->address1 ?: '',
|
||||
|
@ -295,6 +295,7 @@ class CreateUsersTable extends Migration
|
||||
|
||||
$table->decimal('balance', 16, 4)->default(0);
|
||||
$table->decimal('paid_to_date', 16, 4)->default(0);
|
||||
$table->decimal('credit_balance', 16, 4)->default(0);
|
||||
$table->timestamp('last_login')->nullable();
|
||||
$table->unsignedInteger('industry_id')->nullable();
|
||||
$table->unsignedInteger('size_id')->nullable();
|
||||
|
@ -129,7 +129,12 @@ class PaymentTest extends TestCase
|
||||
|
||||
$data = [
|
||||
'amount' => $this->invoice->amount,
|
||||
'invoices' => $this->invoice->hashed_id,
|
||||
'invoices' => [
|
||||
[
|
||||
'id' => $this->invoice->hashed_id,
|
||||
'amount' => $this->invoice->amount
|
||||
],
|
||||
],
|
||||
'payment_date' => '2020/12/11',
|
||||
|
||||
];
|
||||
@ -174,11 +179,17 @@ class PaymentTest extends TestCase
|
||||
$data = [
|
||||
'amount' => $this->invoice->amount,
|
||||
'client_id' => $client->hashed_id,
|
||||
'invoices' => $this->invoice->hashed_id,
|
||||
'invoices' => [
|
||||
[
|
||||
'id' => $this->invoice->hashed_id,
|
||||
'amount' => $this->invoice->amount
|
||||
],
|
||||
],
|
||||
'payment_date' => '2020/12/12',
|
||||
|
||||
];
|
||||
|
||||
$response = null;
|
||||
|
||||
try {
|
||||
$response = $this->withHeaders([
|
||||
@ -194,12 +205,20 @@ class PaymentTest extends TestCase
|
||||
$this->assertNotNull($message);
|
||||
|
||||
}
|
||||
|
||||
$arr = $response->json();
|
||||
// \Log::error($arr);
|
||||
$response->assertStatus(200);
|
||||
|
||||
|
||||
if($response){
|
||||
$arr = $response->json();
|
||||
$response->assertStatus(200);
|
||||
|
||||
$payment_id = $arr['data']['id'];
|
||||
|
||||
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
|
||||
|
||||
$this->assertNotNull($payment);
|
||||
$this->assertNotNull($payment->invoices());
|
||||
$this->assertEquals(1, $payment->invoices()->count());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function testStorePaymentWithNoInvoiecs()
|
||||
@ -212,7 +231,7 @@ class PaymentTest extends TestCase
|
||||
$this->invoice->status_id = Invoice::STATUS_SENT;
|
||||
|
||||
$this->invoice->line_items = $this->buildLineItems();
|
||||
$this->invoice->uses_inclusive_Taxes = false;
|
||||
$this->invoice->uses_inclusive_taxes = false;
|
||||
|
||||
$this->invoice->save();
|
||||
|
||||
@ -241,9 +260,72 @@ class PaymentTest extends TestCase
|
||||
catch(ValidationException $e) {
|
||||
$message = json_decode($e->validator->getMessageBag(),1);
|
||||
$this->assertNotNull($message);
|
||||
|
||||
}
|
||||
|
||||
if($response)
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function testPartialPaymentAmount()
|
||||
{
|
||||
$this->invoice = null;
|
||||
|
||||
$client = ClientFactory::create($this->company->id, $this->user->id);
|
||||
$client->save();
|
||||
|
||||
$this->invoice = InvoiceFactory::create($this->company->id,$this->user->id);//stub the company and user_id
|
||||
$this->invoice->client_id = $client->id;
|
||||
|
||||
$this->invoice->partial = 2.0;
|
||||
$this->invoice->line_items = $this->buildLineItems();
|
||||
$this->invoice->uses_inclusive_taxes = false;
|
||||
|
||||
$this->invoice->save();
|
||||
|
||||
$this->invoice_calc = new InvoiceSum($this->invoice);
|
||||
$this->invoice_calc->build();
|
||||
|
||||
$this->invoice = $this->invoice_calc->getInvoice();
|
||||
$this->invoice->save();
|
||||
$this->invoice->markSent();
|
||||
$this->invoice->save();
|
||||
|
||||
|
||||
$data = [
|
||||
'amount' => 2.0,
|
||||
'client_id' => $client->hashed_id,
|
||||
'invoices' => [
|
||||
[
|
||||
'id' => $this->invoice->hashed_id,
|
||||
'amount' => 2.0
|
||||
],
|
||||
],
|
||||
'payment_date' => '2019/12/12',
|
||||
];
|
||||
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'X-API-SECRET' => config('ninja.api_secret'),
|
||||
'X-API-TOKEN' => $this->token,
|
||||
])->post('/api/v1/payments?include=invoices', $data);
|
||||
|
||||
$arr = $response->json();
|
||||
$response->assertStatus(200);
|
||||
|
||||
$payment_id = $arr['data']['id'];
|
||||
|
||||
$payment = Payment::find($this->decodePrimaryKey($payment_id))->first();
|
||||
|
||||
$this->assertNotNull($payment);
|
||||
$this->assertNotNull($payment->invoices());
|
||||
$this->assertEquals(1, $payment->invoices()->count());
|
||||
|
||||
$pivot_invoice = $payment->invoices()->first();
|
||||
$this->assertEquals($pivot_invoice->pivot->amount, 2);
|
||||
$this->assertEquals($pivot_invoice->partial, 0);
|
||||
$this->assertEquals($pivot_invoice->amount, 10.0000);
|
||||
$this->assertEquals($pivot_invoice->balance, 8.0000);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user